From a21e3c264962d17113014cc45db2b12f638cc989 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 2 Mar 2023 15:51:17 -0800 Subject: [PATCH 1/3] widgets/app [nfc]: Pull UI out of data-management code --- lib/widgets/app.dart | 38 ++++++++++++++++++++------------------ 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/lib/widgets/app.dart b/lib/widgets/app.dart index a5293b4d6f..f265cb332b 100644 --- a/lib/widgets/app.dart +++ b/lib/widgets/app.dart @@ -9,13 +9,29 @@ class ZulipApp extends StatelessWidget { @override Widget build(BuildContext context) { + 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)); // Just one account for now. - return const PerAccountRoot(); + return PerAccountRoot( + child: MaterialApp( + title: 'Zulip', + theme: theme, + home: const HomePage())); } } class PerAccountRoot extends StatefulWidget { - const PerAccountRoot({super.key}); + const PerAccountRoot({super.key, required this.child}); + + final Widget child; @override State createState() => _PerAccountRootState(); @@ -47,23 +63,9 @@ class _PerAccountRootState extends State { @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: 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(), - )); + return PerAccountStoreWidget(store: store!, child: widget.child); } } From 839dd6b4bcb5f10d316b97dab322e6d14ee38212 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 2 Mar 2023 16:11:25 -0800 Subject: [PATCH 2/3] widgets/store [nfc]: Move data-managing widgets to their own file --- lib/widgets/app.dart | 71 +--------------------------------- lib/widgets/compose_box.dart | 2 +- lib/widgets/content.dart | 2 +- lib/widgets/message_list.dart | 2 +- lib/widgets/store.dart | 72 +++++++++++++++++++++++++++++++++++ 5 files changed, 76 insertions(+), 73 deletions(-) create mode 100644 lib/widgets/store.dart diff --git a/lib/widgets/app.dart b/lib/widgets/app.dart index f265cb332b..0e1f6b9306 100644 --- a/lib/widgets/app.dart +++ b/lib/widgets/app.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'compose_box.dart'; import 'message_list.dart'; -import '../model/store.dart'; +import 'store.dart'; class ZulipApp extends StatelessWidget { const ZulipApp({super.key}); @@ -28,81 +28,12 @@ class ZulipApp extends StatelessWidget { } } -class PerAccountRoot extends StatefulWidget { - const PerAccountRoot({super.key, required this.child}); - - final Widget child; - - @override - State createState() => _PerAccountRootState(); -} - -class _PerAccountRootState extends State { - 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) { - // 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); - } -} - /// The Zulip "brand color", a purplish blue. /// /// This is chosen as the sRGB midpoint of the Zulip logo's gradient. // 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 { - 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(); - 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}); diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index e83d8d930b..69ba6ded39 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -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; diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index 889855da9f..5039f898df 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -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; diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index d818e6b77d..27c328a62d 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -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); diff --git a/lib/widgets/store.dart b/lib/widgets/store.dart new file mode 100644 index 0000000000..9b5c7765f0 --- /dev/null +++ b/lib/widgets/store.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; + +import '../model/store.dart'; + +class PerAccountRoot extends StatefulWidget { + const PerAccountRoot({super.key, required this.child}); + + final Widget child; + + @override + State createState() => _PerAccountRootState(); +} + +class _PerAccountRootState extends State { + 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) { + // 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 { + 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(); + 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()); + } +} From a6f99343f4c28b02a6b0d72a77c0146b8c862934 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 2 Mar 2023 15:15:43 -0800 Subject: [PATCH 3/3] store: Add a GlobalStore and corresponding root widget --- lib/model/store.dart | 52 ++++++++++++++++++++++++++++-- lib/widgets/app.dart | 15 +++++---- lib/widgets/store.dart | 73 +++++++++++++++++++++++++++++++++++++++--- 3 files changed, 127 insertions(+), 13 deletions(-) diff --git a/lib/model/store.dart b/lib/model/store.dart index f31591ec93..5d77f4ca9c 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -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 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 load() async { + const accounts = {fixtureAccountId: _fixtureAccount}; + return GlobalStore._(accounts: accounts); + } + + final Map _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 get accounts => _accounts.values; + + Account? getAccount(int id) => _accounts[id]; + + // TODO add setters/mutators; will want to write to database + // Future insertAccount... + // Future 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. +} + +/// 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, @@ -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 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 load(Account account) async { final connection = ApiConnection(auth: account); final stopwatch = Stopwatch()..start(); @@ -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}); diff --git a/lib/widgets/app.dart b/lib/widgets/app.dart index 0e1f6b9306..17336cffbe 100644 --- a/lib/widgets/app.dart +++ b/lib/widgets/app.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import '../model/store.dart'; import 'compose_box.dart'; import 'message_list.dart'; import 'store.dart'; @@ -19,12 +20,14 @@ class ZulipApp extends StatelessWidget { // Or try this tool to see the whole palette: // https://m3.material.io/theme-builder#/custom colorScheme: ColorScheme.fromSeed(seedColor: kZulipBrandColor)); - // Just one account for now. - return PerAccountRoot( - child: MaterialApp( - title: 'Zulip', - theme: theme, - home: const HomePage())); + return DataRoot( + child: PerAccountRoot( + // Just one account for now. + accountId: GlobalStore.fixtureAccountId, + child: MaterialApp( + title: 'Zulip', + theme: theme, + home: const HomePage()))); } } diff --git a/lib/widgets/store.dart b/lib/widgets/store.dart index 9b5c7765f0..86bb2452a9 100644 --- a/lib/widgets/store.dart +++ b/lib/widgets/store.dart @@ -2,9 +2,62 @@ 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 createState() => _DataRootState(); +} + +class _DataRootState extends State { + 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 { + 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(); + 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.child}); + const PerAccountRoot( + {super.key, required this.accountId, required this.child}); + final int accountId; final Widget child; @override @@ -15,10 +68,22 @@ class _PerAccountRootState extends State { PerAccountStore? store; @override - void initState() { - super.initState(); + 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(); + final store = await PerAccountStore.load(account!); setState(() { this.store = store; });