Merge pull request #23 from krawieck/accounts-store

This commit is contained in:
Filip Krawczyk 2020-09-02 17:00:06 +02:00 committed by GitHub
commit de7d059e9e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 592 additions and 162 deletions

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:provider/provider.dart';
import 'stores/accounts_store.dart';
import 'stores/config_store.dart';
Future<void> main() async {
@ -10,6 +11,9 @@ Future<void> main() async {
var configStore = ConfigStore();
await configStore.load();
var accountsStore = AccountsStore();
await accountsStore.load();
runApp(
MultiProvider(
providers: [
@ -17,6 +21,10 @@ Future<void> main() async {
create: (_) => configStore,
dispose: (_, store) => store.dispose(),
),
Provider<AccountsStore>(
create: (_) => accountsStore,
dispose: (_, store) => store.dispose(),
),
],
child: MyApp(),
),
@ -31,7 +39,6 @@ class MyApp extends StatelessWidget {
themeMode: ctx.watch<ConfigStore>().theme,
darkTheme: ThemeData.dark(),
theme: ThemeData(
primarySwatch: ctx.watch<ConfigStore>().accentColor,
visualDensity: VisualDensity.adaptivePlatformDensity,
),
home: MyHomePage(title: 'Flutter hello world'),

View File

@ -1,64 +1,118 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:lemmy_api_client/lemmy_api_client.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:provider/provider.dart';
import '../stores/accounts_store.dart';
import '../widgets/bottom_modal.dart';
import '../widgets/user_profile.dart';
import 'settings.dart';
class UserProfileTab extends HookWidget {
final User user;
UserProfileTab(this.user);
UserProfileTab();
@override
Widget build(BuildContext context) {
var theme = Theme.of(context);
return Scaffold(
extendBodyBehindAppBar: true,
appBar: AppBar(
backgroundColor: Colors.transparent,
shadowColor: Colors.transparent,
centerTitle: true,
title: FlatButton(
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'@${user.name}',
style: TextStyle(color: Colors.white),
),
Icon(
Icons.expand_more,
color: theme.primaryIconTheme.color,
return Observer(
builder: (ctx) {
var user = ctx.watch<AccountsStore>().defaultUser;
return Scaffold(
extendBodyBehindAppBar: true,
appBar: AppBar(
backgroundColor: Colors.transparent,
shadowColor: Colors.transparent,
centerTitle: true,
title: FlatButton(
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'@${user.name}',
style: TextStyle(color: Colors.white),
),
Icon(
Icons.expand_more,
color: theme.primaryIconTheme.color,
),
],
),
onPressed: () {
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
builder: (_) {
var userTags = <String>[];
ctx
.read<AccountsStore>()
.users
.forEach((instanceUrl, value) {
value.forEach((username, _) {
userTags.add('$username@$instanceUrl');
});
});
return Observer(
builder: (ctx) {
var user = ctx.watch<AccountsStore>().defaultUser;
var instanceUrl = user.actorId.split('/')[2];
return BottomModal(
title: 'account',
child: Column(
children: [
for (final tag in userTags)
RadioListTile<String>(
value: tag,
title: Text(tag),
groupValue: '${user.name}@$instanceUrl',
onChanged: (selected) {
var userTag = selected.split('@');
ctx.read<AccountsStore>().setDefaultAccount(
userTag[1], userTag[0]);
Navigator.of(ctx).pop();
},
)
],
),
);
},
);
},
);
},
),
actions: [
IconButton(
icon: Container(
decoration: BoxDecoration(boxShadow: [
BoxShadow(
blurRadius: 10,
color: Colors.black54,
)
]),
child: Icon(
Icons.settings,
color: user.banner == null ? theme.iconTheme.color : null,
),
),
onPressed: () {
Navigator.of(context)
.push(MaterialPageRoute(builder: (_) => Settings()));
},
)
],
),
onPressed: () {}, // TODO: should open bottomsheet
),
actions: [
IconButton(
icon: Container(
decoration: BoxDecoration(boxShadow: [
BoxShadow(
blurRadius: 10,
color: Colors.black54,
)
]),
child: Icon(
Icons.settings,
color: user.banner == null ? theme.iconTheme.color : null,
),
),
onPressed: () {
Navigator.of(context)
.push(MaterialPageRoute(builder: (_) => Settings()));
},
)
],
),
body: UserProfile(user),
body: UserProfile(
userId: user.id,
instanceUrl: user.actorId.split('/')[2],
),
);
},
);
}
}

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:provider/provider.dart';
import '../stores/accounts_store.dart';
import '../stores/config_store.dart';
class Settings extends StatelessWidget {
@ -23,7 +24,10 @@ class Settings extends StatelessWidget {
ListTile(
leading: Icon(Icons.person),
title: Text('Accounts'),
onTap: () {},
onTap: () {
Navigator.of(context)
.push(MaterialPageRoute(builder: (_) => _AccountsConfig()));
},
),
ListTile(
leading: Icon(Icons.color_lens),
@ -69,14 +73,60 @@ class _AppearanceConfig extends StatelessWidget {
ctx.read<ConfigStore>().theme = selected;
},
),
Text(
'Accent color',
style: theme.textTheme.headline6,
),
// TODO: add accent color picking
],
),
),
);
}
}
class _AccountsConfig extends StatelessWidget {
@override
Widget build(BuildContext context) {
var theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
backgroundColor: theme.scaffoldBackgroundColor,
shadowColor: Colors.transparent,
iconTheme: theme.iconTheme,
title: Text('Accounts', style: theme.textTheme.headline6),
centerTitle: true,
),
body: Observer(
builder: (ctx) {
var accountsStore = ctx.watch<AccountsStore>();
var theme = Theme.of(context);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
for (var entry in accountsStore.users.entries) ...[
Text(
entry.key,
style: theme.textTheme.subtitle2,
),
for (var username in entry.value.keys) ...[
ListTile(
trailing:
username == accountsStore.defaultUserFor(entry.key).name
? Icon(Icons.check_circle_outline)
: null,
selected: username ==
accountsStore.defaultUserFor(entry.key).name,
title: Text(username),
onLongPress: () {
accountsStore.setDefaultAccountFor(entry.key, username);
},
onTap: () {}, // TODO: go to managing account
),
],
Divider(),
]
]..removeLast(), // removes trailing Divider
);
},
),
);
}
}

View File

@ -0,0 +1,135 @@
import 'dart:convert';
import 'package:lemmy_api_client/lemmy_api_client.dart';
import 'package:mobx/mobx.dart';
import 'package:shared_preferences/shared_preferences.dart';
part 'accounts_store.g.dart';
class AccountsStore extends _AccountsStore with _$AccountsStore {}
abstract class _AccountsStore with Store {
ReactionDisposer _saveReactionDisposer;
_AccountsStore() {
// persistently save settings each time they are changed
_saveReactionDisposer = reaction(
// TODO: does not react to deep changes in users and tokens
(_) => [
users.asObservable(),
tokens.asObservable(),
_defaultAccount,
_defaultAccounts.asObservable(),
],
(_) {
save();
},
);
}
void dispose() {
_saveReactionDisposer();
}
void load() async {
var prefs = await SharedPreferences.getInstance();
// set saved settings or create defaults
// TODO: load saved users and tokens
users = ObservableMap();
tokens = ObservableMap();
_defaultAccount = prefs.getString('defaultAccount');
_defaultAccounts = ObservableMap.of(Map.castFrom(
jsonDecode(prefs.getString('defaultAccounts') ?? 'null') ?? {}));
}
void save() async {
var prefs = await SharedPreferences.getInstance();
await prefs.setString('defaultAccount', _defaultAccount);
await prefs.setString('defaultAccounts', jsonEncode(_defaultAccounts));
await prefs.setString('users', jsonEncode(users));
await prefs.setString('tokens', jsonEncode(tokens));
}
/// if path to tokens map exists, it exists for users as well
/// `users['instanceUrl']['username']`
@observable
ObservableMap<String, ObservableMap<String, User>> users;
/// if path to users map exists, it exists for tokens as well
/// `tokens['instanceUrl']['username']`
@observable
ObservableMap<String, ObservableMap<String, Jwt>> tokens;
/// default account for a given instance
/// map where keys are instanceUrls and values are usernames
@observable
ObservableMap<String, String> _defaultAccounts;
/// default account for the app
/// username@instanceUrl
@observable
String _defaultAccount;
@computed
User get defaultUser {
var userTag = _defaultAccount.split('@');
return users[userTag[1]][userTag[0]];
}
@computed
Jwt get defaultToken {
var userTag = _defaultAccount.split('@');
return tokens[userTag[1]][userTag[0]];
}
User defaultUserFor(String instanceUrl) =>
Computed(() => users[instanceUrl][_defaultAccounts[instanceUrl]]).value;
Jwt defaultTokenFor(String instanceUrl) =>
Computed(() => tokens[instanceUrl][_defaultAccounts[instanceUrl]]).value;
@action
void setDefaultAccount(String instanceUrl, String username) {
_defaultAccount = '$username@$instanceUrl';
}
@action
void setDefaultAccountFor(String instanceUrl, String username) {
_defaultAccounts[instanceUrl] = username;
}
/// adds a new account
/// if it's the first account ever the account is
/// set as default for the app
/// if it's the first account for an instance the account is
/// set as default for that instance
@action
Future<void> addAccount(
String instanceUrl,
String usernameOrEmail,
String password,
) async {
var lemmy = LemmyApi(instanceUrl).v1;
var token = await lemmy.login(
usernameOrEmail: usernameOrEmail,
password: password,
);
var userData =
await lemmy.getSite(auth: token.raw).then((value) => value.myUser);
if (!users.containsKey(instanceUrl)) {
if (users.isEmpty) {
setDefaultAccount(instanceUrl, userData.name);
}
users[instanceUrl] = ObservableMap();
tokens[instanceUrl] = ObservableMap();
setDefaultAccountFor(instanceUrl, userData.name);
}
users[instanceUrl][userData.name] = userData;
tokens[instanceUrl][userData.name] = token;
}
}

View File

@ -0,0 +1,130 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'accounts_store.dart';
// **************************************************************************
// StoreGenerator
// **************************************************************************
// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic
mixin _$AccountsStore on _AccountsStore, Store {
Computed<User> _$defaultUserComputed;
@override
User get defaultUser =>
(_$defaultUserComputed ??= Computed<User>(() => super.defaultUser,
name: '_AccountsStore.defaultUser'))
.value;
Computed<Jwt> _$defaultTokenComputed;
@override
Jwt get defaultToken =>
(_$defaultTokenComputed ??= Computed<Jwt>(() => super.defaultToken,
name: '_AccountsStore.defaultToken'))
.value;
final _$usersAtom = Atom(name: '_AccountsStore.users');
@override
ObservableMap<String, ObservableMap<String, User>> get users {
_$usersAtom.reportRead();
return super.users;
}
@override
set users(ObservableMap<String, ObservableMap<String, User>> value) {
_$usersAtom.reportWrite(value, super.users, () {
super.users = value;
});
}
final _$tokensAtom = Atom(name: '_AccountsStore.tokens');
@override
ObservableMap<String, ObservableMap<String, Jwt>> get tokens {
_$tokensAtom.reportRead();
return super.tokens;
}
@override
set tokens(ObservableMap<String, ObservableMap<String, Jwt>> value) {
_$tokensAtom.reportWrite(value, super.tokens, () {
super.tokens = value;
});
}
final _$_defaultAccountsAtom = Atom(name: '_AccountsStore._defaultAccounts');
@override
ObservableMap<String, String> get _defaultAccounts {
_$_defaultAccountsAtom.reportRead();
return super._defaultAccounts;
}
@override
set _defaultAccounts(ObservableMap<String, String> value) {
_$_defaultAccountsAtom.reportWrite(value, super._defaultAccounts, () {
super._defaultAccounts = value;
});
}
final _$_defaultAccountAtom = Atom(name: '_AccountsStore._defaultAccount');
@override
String get _defaultAccount {
_$_defaultAccountAtom.reportRead();
return super._defaultAccount;
}
@override
set _defaultAccount(String value) {
_$_defaultAccountAtom.reportWrite(value, super._defaultAccount, () {
super._defaultAccount = value;
});
}
final _$addAccountAsyncAction = AsyncAction('_AccountsStore.addAccount');
@override
Future<void> addAccount(
String instanceUrl, String usernameOrEmail, String password) {
return _$addAccountAsyncAction
.run(() => super.addAccount(instanceUrl, usernameOrEmail, password));
}
final _$_AccountsStoreActionController =
ActionController(name: '_AccountsStore');
@override
void setDefaultAccount(String instanceUrl, String username) {
final _$actionInfo = _$_AccountsStoreActionController.startAction(
name: '_AccountsStore.setDefaultAccount');
try {
return super.setDefaultAccount(instanceUrl, username);
} finally {
_$_AccountsStoreActionController.endAction(_$actionInfo);
}
}
@override
void setDefaultAccountFor(String instanceUrl, String username) {
final _$actionInfo = _$_AccountsStoreActionController.startAction(
name: '_AccountsStore.setDefaultAccountFor');
try {
return super.setDefaultAccountFor(instanceUrl, username);
} finally {
_$_AccountsStoreActionController.endAction(_$actionInfo);
}
}
@override
String toString() {
return '''
users: ${users},
tokens: ${tokens},
defaultUser: ${defaultUser},
defaultToken: ${defaultToken}
''';
}
}

View File

@ -23,7 +23,7 @@ abstract class _ConfigStore with Store {
void load() async {
var prefs = await SharedPreferences.getInstance();
// set saved settings or create defaults
// load saved settings or create defaults
theme = _themeModeFromString(prefs.getString('theme') ?? 'system');
}
@ -36,8 +36,7 @@ abstract class _ConfigStore with Store {
@observable
ThemeMode theme;
@observable
MaterialColor accentColor;
// TODO: add amoledDarkMode switch
}
ThemeMode _themeModeFromString(String theme) =>

View File

@ -24,26 +24,10 @@ mixin _$ConfigStore on _ConfigStore, Store {
});
}
final _$accentColorAtom = Atom(name: '_ConfigStore.accentColor');
@override
MaterialColor get accentColor {
_$accentColorAtom.reportRead();
return super.accentColor;
}
@override
set accentColor(MaterialColor value) {
_$accentColorAtom.reportWrite(value, super.accentColor, () {
super.accentColor = value;
});
}
@override
String toString() {
return '''
theme: ${theme},
accentColor: ${accentColor}
theme: ${theme}
''';
}
}

24
lib/widgets/badge.dart Normal file
View File

@ -0,0 +1,24 @@
import 'package:flutter/material.dart';
class Badge extends StatelessWidget {
final Widget child;
Badge({@required this.child});
@override
Widget build(BuildContext context) {
var theme = Theme.of(context);
return Container(
height: 25,
decoration: BoxDecoration(
color: theme.accentColor,
borderRadius: BorderRadius.all(Radius.circular(5)),
),
child: Padding(
padding: const EdgeInsets.all(4.0),
child: child,
),
);
}
}

View File

@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
class BottomModal extends StatelessWidget {
final Widget child;
final String title;
BottomModal({@required this.child, this.title});
@override
Widget build(BuildContext context) {
var theme = Theme.of(context);
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Container(
padding: const EdgeInsets.only(top: 10),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.all(const Radius.circular(10.0)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (title != null) ...[
Padding(
padding: const EdgeInsets.only(left: 70),
child: Text(
'account',
style: theme.textTheme.subtitle2,
textAlign: TextAlign.left,
),
),
Divider()
],
child,
],
),
),
),
);
}
}

View File

@ -6,26 +6,44 @@ import 'package:lemmy_api_client/lemmy_api_client.dart';
import 'package:timeago/timeago.dart' as timeago;
import '../util/intl.dart';
import 'badge.dart';
class UserProfile extends HookWidget {
final User user;
final int userId;
final Future<UserView> _userView;
final String _instanceUrl;
final String instanceUrl;
UserProfile(this.user)
: _instanceUrl = user.actorId.split('/')[2],
_userView = LemmyApi(user.actorId.split('/')[2])
UserProfile({@required this.userId, @required this.instanceUrl})
: _userView = LemmyApi(instanceUrl)
.v1
.search(q: user.name, type: SearchType.users, sort: SortType.active)
.then((res) => res.users[0]);
.getUserDetails(
userId: userId, savedOnly: true, sort: SortType.active)
.then((res) => res.user);
@override
Widget build(BuildContext context) {
var theme = Theme.of(context);
var userViewSnap = useFuture(_userView);
var userViewSnap = useFuture(_userView, preserveState: false);
Widget _tabs() => DefaultTabController(
Widget bio;
if (userViewSnap.hasData) {
if (userViewSnap.data.bio != null) {
bio = Text(userViewSnap.data.bio);
} else {
bio = Center(
child: Text(
'no bio',
style: const TextStyle(fontStyle: FontStyle.italic),
),
);
}
} else {
bio = Center(child: CircularProgressIndicator());
}
Widget tabs() => DefaultTabController(
length: 3,
child: Column(
children: [
@ -41,24 +59,18 @@ class UserProfile extends HookWidget {
child: TabBarView(
children: [
Center(
child: Text(
'Posts',
style: const TextStyle(fontSize: 36),
)),
child: Text(
'Posts',
style: const TextStyle(fontSize: 36),
),
),
Center(
child: Text(
'Comments',
style: const TextStyle(fontSize: 36),
)),
if (user.bio == null)
Center(
child: Text(
'no bio',
style: const TextStyle(fontStyle: FontStyle.italic),
),
)
else
Text(user.bio),
child: Text(
'Comments',
style: const TextStyle(fontSize: 36),
),
),
bio,
],
),
)
@ -69,9 +81,9 @@ class UserProfile extends HookWidget {
return Center(
child: Stack(
children: [
if (user.banner != null)
if (userViewSnap.data?.banner != null)
CachedNetworkImage(
imageUrl: user.banner,
imageUrl: userViewSnap.data.banner,
)
else
Container(
@ -87,7 +99,10 @@ class UserProfile extends HookWidget {
height: double.infinity,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(40)),
borderRadius: BorderRadius.only(
topRight: Radius.circular(40),
topLeft: Radius.circular(40),
),
color: theme.scaffoldBackgroundColor,
),
),
@ -97,7 +112,7 @@ class UserProfile extends HookWidget {
SafeArea(
child: Column(
children: [
if (user.avatar != null)
if (userViewSnap.data?.avatar != null)
SizedBox(
width: 80,
height: 80,
@ -113,24 +128,26 @@ class UserProfile extends HookWidget {
child: ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(12)),
child: CachedNetworkImage(
imageUrl: user.avatar,
imageUrl: userViewSnap.data.avatar,
),
),
),
),
Padding(
padding: user.avatar == null
? const EdgeInsets.only(top: 70)
: const EdgeInsets.only(top: 8.0),
padding: userViewSnap.data?.avatar != null
? const EdgeInsets.only(top: 8.0)
: const EdgeInsets.only(top: 70),
child: Text(
user.preferredUsername ?? user.name,
userViewSnap.data?.preferredUsername ??
userViewSnap.data?.name ??
'',
style: theme.textTheme.headline6,
),
),
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Text(
'@${user.name}@$_instanceUrl',
'@${userViewSnap.data?.name ?? ''}@$instanceUrl',
style: theme.textTheme.caption,
),
),
@ -139,19 +156,39 @@ class UserProfile extends HookWidget {
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_Badge(
icon: Icons.comment, // TODO: should be article icon
text: '''
${compactNumber(userViewSnap.data?.numberOfPosts ?? 0)} Post${pluralS(userViewSnap.data?.numberOfPosts ?? 0)}''',
isLoading: !userViewSnap.hasData,
Badge(
child: Row(
children: [
Icon(
Icons.comment, // TODO: should be article icon
size: 15,
color: Colors.white,
),
Padding(
padding: const EdgeInsets.only(left: 4.0),
child: Text('''
${userViewSnap.hasData ? compactNumber(userViewSnap.data.numberOfPosts) : '-'} Post${pluralS(userViewSnap.data?.numberOfPosts ?? 0)}'''),
),
],
),
),
Padding(
padding: const EdgeInsets.only(left: 16.0),
child: _Badge(
icon: Icons.comment,
text: '''
${compactNumber(userViewSnap.data?.numberOfComments ?? 0)} Comment${pluralS(userViewSnap.data?.numberOfComments ?? 1)}''',
isLoading: !userViewSnap.hasData,
child: Badge(
child: Row(
children: [
Icon(
Icons.comment,
size: 15,
color: Colors.white,
),
Padding(
padding: const EdgeInsets.only(left: 4.0),
child: Text('''
${userViewSnap.hasData ? compactNumber(userViewSnap.data.numberOfComments) : '-'} Comment${pluralS(userViewSnap.data?.numberOfComments ?? 0)}'''),
),
],
),
),
),
],
@ -160,7 +197,8 @@ ${compactNumber(userViewSnap.data?.numberOfComments ?? 0)} Comment${pluralS(user
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
'Joined ${timeago.format(user.published)}',
'''
Joined ${userViewSnap.hasData ? timeago.format(userViewSnap.data.published) : ''}''',
style: theme.textTheme.bodyText1,
),
),
@ -174,13 +212,16 @@ ${compactNumber(userViewSnap.data?.numberOfComments ?? 0)} Comment${pluralS(user
Padding(
padding: const EdgeInsets.only(left: 4.0),
child: Text(
DateFormat('MMM dd, yyyy').format(user.published),
userViewSnap.hasData
? DateFormat('MMM dd, yyyy')
.format(userViewSnap.data.published)
: '',
style: theme.textTheme.bodyText1,
),
),
],
),
Expanded(child: _tabs())
Expanded(child: tabs())
],
),
),
@ -189,41 +230,3 @@ ${compactNumber(userViewSnap.data?.numberOfComments ?? 0)} Comment${pluralS(user
);
}
}
class _Badge extends StatelessWidget {
final IconData icon;
final String text;
final bool isLoading;
_Badge({
@required this.icon,
@required this.isLoading,
@required this.text,
});
@override
Widget build(BuildContext context) {
var theme = Theme.of(context);
return Container(
decoration: BoxDecoration(
color: theme.accentColor,
borderRadius: BorderRadius.all(Radius.circular(5)),
),
child: Padding(
padding: const EdgeInsets.all(4.0),
child: isLoading
? CircularProgressIndicator()
: Row(
children: [
Icon(icon, size: 15, color: Colors.white),
Padding(
padding: const EdgeInsets.only(left: 4.0),
child: Text(text),
),
],
),
),
);
}
}

View File

@ -344,7 +344,7 @@ packages:
name: lemmy_api_client
url: "https://pub.dartlang.org"
source: hosted
version: "0.2.1"
version: "0.3.0"
logging:
dependency: transitive
description:

View File

@ -28,7 +28,7 @@ dependencies:
flutter_hooks: ^0.13.2
cached_network_image: ^2.2.0+1
timeago: ^2.0.27
lemmy_api_client: ^0.2.0
lemmy_api_client: ^0.3.0
mobx: ^1.2.1
flutter_mobx: ^1.1.0