Merge branch 'krille/multi-account' into 'main'

feat: Allow loading of multiple clients in main.dart

Closes #534

See merge request famedly/fluffychat!522
This commit is contained in:
Krille Fear 2021-09-19 11:48:24 +00:00
commit b7d5bd4274
26 changed files with 840 additions and 159 deletions

View File

@ -1561,6 +1561,12 @@
"type": "text",
"placeholders": {}
},
"addAccount": "Add account",
"editBundlesForAccount": "Edit bundles for this account",
"addToBundle": "Add to bundle",
"removeFromBundle": "Remove from this bundle",
"bundleName": "Bundle name",
"enableMultiAccounts": "Enable multi accounts on this device",
"openInMaps": "Open in maps",
"@openInMaps": {
"type": "text",

View File

@ -21,6 +21,6 @@
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>8.0</string>
<string>9.0</string>
</dict>
</plist>

View File

@ -217,12 +217,12 @@ class AppRoutes {
buildTransition: _fadeTransition,
stackedRoutes: [
VWidget(
path: '/login',
path: 'login',
widget: Login(),
buildTransition: _fadeTransition,
),
VWidget(
path: '/signup',
path: 'signup',
widget: SignupPage(),
buildTransition: _fadeTransition,
),
@ -296,6 +296,23 @@ class AppRoutes {
widget: DevicesSettings(),
buildTransition: _dynamicTransition,
),
VWidget(
path: 'add',
widget: HomeserverPicker(),
buildTransition: _fadeTransition,
stackedRoutes: [
VWidget(
path: 'login',
widget: Login(),
buildTransition: _fadeTransition,
),
VWidget(
path: 'signup',
widget: SignupPage(),
buildTransition: _fadeTransition,
),
],
),
],
),
VWidget(

View File

@ -8,6 +8,8 @@ import 'app_config.dart';
abstract class FluffyThemes {
static const double columnWidth = 360.0;
static bool isColumnMode(BuildContext context) =>
MediaQuery.of(context).size.width > columnWidth * 2;
static const fallbackTextStyle =
TextStyle(fontFamily: 'NotoSans', fontFamilyFallback: ['NotoEmoji']);

View File

@ -2,7 +2,7 @@
import 'dart:async';
import 'package:adaptive_theme/adaptive_theme.dart';
import 'package:matrix/encryption/utils/key_verification.dart';
import 'package:fluffychat/utils/client_manager.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/config/routes.dart';
import 'package:fluffychat/utils/platform_infos.dart';
@ -18,8 +18,6 @@ import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'package:universal_html/html.dart' as html;
import 'package:vrouter/vrouter.dart';
import 'utils/matrix_sdk_extensions.dart/flutter_matrix_hive_database.dart';
import 'widgets/layouts/wait_for_login.dart';
import 'widgets/lock_screen.dart';
import 'widgets/matrix.dart';
import 'config/themes.dart';
@ -35,27 +33,10 @@ void main() async {
FlutterError.onError = (FlutterErrorDetails details) =>
Zone.current.handleUncaughtError(details.exception, details.stack);
final client = Client(
PlatformInfos.clientName,
enableE2eeRecovery: true,
verificationMethods: {
KeyVerificationMethod.numbers,
if (PlatformInfos.isMobile || PlatformInfos.isLinux)
KeyVerificationMethod.emoji,
},
importantStateEvents: <String>{
'im.ponies.room_emotes', // we want emotes to work properly
},
databaseBuilder: FlutterMatrixHiveStore.hiveDatabaseBuilder,
supportedLoginTypes: {
AuthenticationTypes.password,
if (PlatformInfos.isMobile || PlatformInfos.isWeb) AuthenticationTypes.sso
},
compute: compute,
);
final clients = await ClientManager.getClients();
if (PlatformInfos.isMobile) {
BackgroundPush.clientOnly(client);
BackgroundPush.clientOnly(clients.first);
}
final queryParameters = <String, String>{};
@ -68,24 +49,24 @@ void main() async {
() => runApp(PlatformInfos.isMobile
? AppLock(
builder: (args) => FluffyChatApp(
client: client,
clients: clients,
queryParameters: queryParameters,
),
lockScreen: LockScreen(),
enabled: false,
)
: FluffyChatApp(client: client, queryParameters: queryParameters)),
: FluffyChatApp(clients: clients, queryParameters: queryParameters)),
SentryController.captureException,
);
}
class FluffyChatApp extends StatefulWidget {
final Widget testWidget;
final Client client;
final List<Client> clients;
final Map<String, String> queryParameters;
const FluffyChatApp(
{Key key, this.testWidget, @required this.client, this.queryParameters})
{Key key, this.testWidget, @required this.clients, this.queryParameters})
: super(key: key);
/// getInitialLink may rereturn the value multiple times if this view is
@ -101,7 +82,15 @@ class _FluffyChatAppState extends State<FluffyChatApp> {
final GlobalKey<MatrixState> _matrix = GlobalKey<MatrixState>();
GlobalKey<VRouterState> _router;
bool columnMode;
String _initialUrl = '/';
String _initialUrl;
@override
void initState() {
super.initState();
_initialUrl =
widget.clients.any((client) => client.isLogged()) ? '/rooms' : '/home';
}
@override
Widget build(BuildContext context) {
return AdaptiveTheme(
@ -159,8 +148,8 @@ class _FluffyChatAppState extends State<FluffyChatApp> {
key: _matrix,
context: context,
router: _router,
client: widget.client,
child: WaitForInitPage(child),
clients: widget.clients,
child: child,
);
},
);

View File

@ -32,6 +32,7 @@ import 'send_location_dialog.dart';
import 'sticker_picker_dialog.dart';
import '../utils/matrix_sdk_extensions.dart/filtered_timeline_extension.dart';
import '../utils/matrix_sdk_extensions.dart/matrix_file_extension.dart';
import '../utils/account_bundles.dart';
class Chat extends StatefulWidget {
final Widget sideView;
@ -45,6 +46,8 @@ class Chat extends StatefulWidget {
class ChatController extends State<Chat> {
Room room;
Client sendingClient;
Timeline timeline;
MatrixState matrix;
@ -222,6 +225,14 @@ class ChatController extends State<Chat> {
TextEditingController sendController = TextEditingController();
void setSendingClient(Client c) => setState(() {
sendingClient = c;
});
void setActiveClient(Client c) => setState(() {
Matrix.of(context).setActiveClient(c);
});
Future<void> send() async {
if (sendController.text.trim().isEmpty) return;
var parseCommands = true;
@ -447,19 +458,51 @@ class ChatController extends State<Chat> {
for (final event in selectedEvents) {
await showFutureLoadingDialog(
context: context,
future: () =>
event.status > 0 ? event.redactEvent() : event.remove());
future: () async {
if (event.status > 0) {
if (event.canRedact) {
await event.redactEvent();
} else {
final client = currentRoomBundle.firstWhere(
(cl) => selectedEvents.first.senderId == cl.userID,
orElse: () => null);
if (client == null) {
return;
}
final room = client.getRoomById(roomId);
await Event.fromJson(event.toJson(), room).redactEvent();
}
} else {
await event.remove();
}
});
}
setState(() => selectedEvents.clear());
}
List<Client> get currentRoomBundle {
final clients = matrix.currentBundle;
clients.removeWhere((c) => c.getRoomById(roomId) == null);
return clients;
}
bool get canRedactSelectedEvents {
final clients = matrix.currentBundle;
for (final event in selectedEvents) {
if (event.canRedact == false) return false;
if (event.canRedact == false &&
!(clients.any((cl) => event.senderId == cl.userID))) return false;
}
return true;
}
bool get canEditSelectedEvents {
if (selectedEvents.length != 1 || selectedEvents.first.status < 1) {
return false;
}
return currentRoomBundle
.any((cl) => selectedEvents.first.senderId == cl.userID);
}
void forwardEventsAction() async {
if (selectedEvents.length == 1) {
Matrix.of(context).shareContent = selectedEvents.first.content;
@ -584,6 +627,13 @@ class ChatController extends State<Chat> {
});
void editSelectedEventAction() {
final client = currentRoomBundle.firstWhere(
(cl) => selectedEvents.first.senderId == cl.userID,
orElse: () => null);
if (client == null) {
return;
}
setSendingClient(client);
setState(() {
pendingText = sendController.text;
editEvent = selectedEvents.first;
@ -689,6 +739,19 @@ class ChatController extends State<Chat> {
}
void onInputBarChanged(String text) {
final clients = currentRoomBundle;
for (final client in clients) {
final prefix = client.sendPrefix;
if ((prefix?.isNotEmpty ?? false) &&
text.toLowerCase() == '${prefix.toLowerCase()} ') {
setSendingClient(client);
setState(() {
inputText = '';
sendController.text = '';
});
return;
}
}
typingCoolDown?.cancel();
typingCoolDown = Timer(Duration(seconds: 2), () {
typingCoolDown = null;

View File

@ -19,6 +19,7 @@ import 'package:uni_links/uni_links.dart';
import 'package:vrouter/vrouter.dart';
import '../main.dart';
import '../widgets/matrix.dart';
import '../../utils/account_bundles.dart';
import '../utils/matrix_sdk_extensions.dart/matrix_file_extension.dart';
import '../utils/url_launcher.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
@ -409,6 +410,93 @@ class ChatListController extends State<ChatList> {
}
}
void setActiveClient(Client client) {
VRouter.of(context).to('/rooms');
setState(() {
_activeSpaceId = null;
selectedRoomIds.clear();
Matrix.of(context).setActiveClient(client);
});
}
void setActiveBundle(String bundle) => setState(() {
_activeSpaceId = null;
selectedRoomIds.clear();
Matrix.of(context).activeBundle = bundle;
if (!Matrix.of(context)
.currentBundle
.any((client) => client == Matrix.of(context).client)) {
Matrix.of(context)
.setActiveClient(Matrix.of(context).currentBundle.first);
}
});
void editBundlesForAccount(String userId) async {
final client = Matrix.of(context)
.widget
.clients[Matrix.of(context).getClientIndexByMatrixId(userId)];
final action = await showConfirmationDialog<EditBundleAction>(
context: context,
title: L10n.of(context).editBundlesForAccount,
actions: [
AlertDialogAction(
key: EditBundleAction.addToBundle,
label: L10n.of(context).addToBundle,
),
if (Matrix.of(context).activeBundle != null)
AlertDialogAction(
key: EditBundleAction.removeFromBundle,
label: L10n.of(context).removeFromBundle,
),
],
);
if (action == null) return;
switch (action) {
case EditBundleAction.addToBundle:
final bundle = await showTextInputDialog(
context: context,
title: L10n.of(context).bundleName,
textFields: [
DialogTextField(hintText: L10n.of(context).bundleName)
]);
if (bundle.isEmpty && bundle.single.isEmpty) return;
await showFutureLoadingDialog(
context: context,
future: () => client.setAccountBundle(bundle.single),
);
break;
case EditBundleAction.removeFromBundle:
await showFutureLoadingDialog(
context: context,
future: () =>
client.removeFromAccountBundle(Matrix.of(context).activeBundle),
);
}
}
bool get displayBundles =>
Matrix.of(context).hasComplexBundles &&
Matrix.of(context).accountBundles.keys.length > 1;
String get secureActiveBundle {
if (Matrix.of(context).activeBundle == null ||
!Matrix.of(context)
.accountBundles
.keys
.contains(Matrix.of(context).activeBundle)) {
return Matrix.of(context).accountBundles.keys.first;
}
return Matrix.of(context).activeBundle;
}
void resetActiveBundle() {
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
setState(() {
Matrix.of(context).activeBundle = null;
});
});
}
@override
Widget build(BuildContext context) {
Matrix.of(context).navigatorContext = context;
@ -424,3 +512,5 @@ class ChatListController extends State<ChatList> {
return ChatListView(this);
}
}
enum EditBundleAction { addToBundle, removeFromBundle }

View File

@ -47,13 +47,13 @@ class HomeserverPickerController extends State<HomeserverPicker> {
showFutureLoadingDialog(
context: context,
future: () async {
if (Matrix.of(context).client.homeserver == null) {
await Matrix.of(context).client.checkHomeserver(
if (Matrix.of(context).getLoginClient().homeserver == null) {
await Matrix.of(context).getLoginClient().checkHomeserver(
await Store()
.getItem(HomeserverPickerController.ssoHomeserverKey),
);
}
await Matrix.of(context).client.login(
await Matrix.of(context).getLoginClient().login(
LoginType.mLoginToken,
token: token,
initialDeviceDisplayName: PlatformInfos.clientName,
@ -117,7 +117,7 @@ class HomeserverPickerController extends State<HomeserverPicker> {
isLoading = true;
});
final wellKnown =
await Matrix.of(context).client.checkHomeserver(homeserver);
await Matrix.of(context).getLoginClient().checkHomeserver(homeserver);
var jitsi = wellKnown?.additionalProperties
?.tryGet<Map<String, dynamic>>('im.vector.riot.jitsi')
@ -177,13 +177,13 @@ class HomeserverPickerController extends State<HomeserverPicker> {
.any((flow) => flow['type'] == AuthenticationTypes.sso);
Future<Map<String, dynamic>> getLoginTypes() async {
_rawLoginTypes ??= await Matrix.of(context).client.request(
_rawLoginTypes ??= await Matrix.of(context).getLoginClient().request(
RequestType.GET,
'/client/r0/login',
);
if (registrationSupported == null) {
try {
await Matrix.of(context).client.register();
await Matrix.of(context).getLoginClient().register();
registrationSupported = true;
} on MatrixException catch (e) {
registrationSupported = e.requireAdditionalAuthentication ?? false;
@ -200,14 +200,14 @@ class HomeserverPickerController extends State<HomeserverPicker> {
if (kIsWeb) {
// We store the homserver in the local storage instead of a redirect
// parameter because of possible CSRF attacks.
Store().setItem(
ssoHomeserverKey, Matrix.of(context).client.homeserver.toString());
Store().setItem(ssoHomeserverKey,
Matrix.of(context).getLoginClient().homeserver.toString());
}
final redirectUrl = kIsWeb
? AppConfig.webBaseUrl + '/#/'
: AppConfig.appOpenUrlScheme.toLowerCase() + '://login';
final url =
'${Matrix.of(context).client.homeserver?.toString()}/_matrix/client/r0/login/sso/redirect/${Uri.encodeComponent(id)}?redirectUrl=${Uri.encodeQueryComponent(redirectUrl)}';
'${Matrix.of(context).getLoginClient().homeserver?.toString()}/_matrix/client/r0/login/sso/redirect/${Uri.encodeComponent(id)}?redirectUrl=${Uri.encodeQueryComponent(redirectUrl)}';
if (PlatformInfos.isMobile) {
browser ??= ChromeSafariBrowser();
browser.open(url: Uri.parse(url));
@ -216,7 +216,7 @@ class HomeserverPickerController extends State<HomeserverPicker> {
}
}
void signUpAction() => VRouter.of(context).to('/signup');
void signUpAction() => VRouter.of(context).to('signup');
bool _initialized = false;

View File

@ -64,7 +64,7 @@ class LoginController extends State<Login> {
} else {
identifier = AuthenticationUserIdentifier(user: username);
}
await matrix.client.login(LoginType.mLoginPassword,
await matrix.getLoginClient().login(LoginType.mLoginPassword,
identifier: identifier,
// To stay compatible with older server versions
// ignore: deprecated_member_use
@ -98,12 +98,13 @@ class LoginController extends State<Login> {
setState(() => usernameError = null);
if (!userId.isValidMatrixId) return;
try {
final oldHomeserver = Matrix.of(context).client.homeserver;
final oldHomeserver = Matrix.of(context).getLoginClient().homeserver;
var newDomain = Uri.https(userId.domain, '');
Matrix.of(context).client.homeserver = newDomain;
Matrix.of(context).getLoginClient().homeserver = newDomain;
DiscoveryInformation wellKnownInformation;
try {
wellKnownInformation = await Matrix.of(context).client.getWellknown();
wellKnownInformation =
await Matrix.of(context).getLoginClient().getWellknown();
if (wellKnownInformation.mHomeserver?.baseUrl?.toString()?.isNotEmpty ??
false) {
newDomain = wellKnownInformation.mHomeserver.baseUrl;
@ -120,8 +121,8 @@ class LoginController extends State<Login> {
.checkHomeserver(newDomain)
.catchError((e) => null),
);
if (Matrix.of(context).client.homeserver == null) {
Matrix.of(context).client.homeserver = oldHomeserver;
if (Matrix.of(context).getLoginClient().homeserver == null) {
Matrix.of(context).getLoginClient().homeserver = oldHomeserver;
// okay, the server we checked does not appear to be a matrix server
Logs().v(
'$newDomain is not running a homeserver, asking to use $oldHomeserver');
@ -178,7 +179,8 @@ class LoginController extends State<Login> {
Matrix.of(context).client.generateUniqueTransactionId();
final response = await showFutureLoadingDialog(
context: context,
future: () => Matrix.of(context).client.requestTokenToResetPasswordEmail(
future: () =>
Matrix.of(context).getLoginClient().requestTokenToResetPasswordEmail(
clientSecret,
input.single,
sendAttempt++,
@ -211,7 +213,7 @@ class LoginController extends State<Login> {
if (password == null) return;
final success = await showFutureLoadingDialog(
context: context,
future: () => Matrix.of(context).client.changePassword(
future: () => Matrix.of(context).getLoginClient().changePassword(
password.single,
auth: AuthenticationThreePidCreds(
type: AuthenticationTypes.emailIdentity,

View File

@ -7,6 +7,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'package:matrix/matrix.dart';
import 'package:vrouter/vrouter.dart';
class SettingsAccount extends StatefulWidget {
const SettingsAccount({Key key}) : super(key: key);
@ -144,6 +145,8 @@ class SettingsAccountController extends State<SettingsAccount> {
);
}
void addAccountAction() => VRouter.of(context).to('add');
@override
Widget build(BuildContext context) {
final client = Matrix.of(context).client;

View File

@ -37,7 +37,7 @@ class SignupPageController extends State<SignupPage> {
setState(() => loading = true);
try {
final client = Matrix.of(context).client;
final client = Matrix.of(context).getLoginClient();
await client.uiaRequestBackground(
(auth) => client.register(
username: usernameController.text,

View File

@ -1,4 +1,9 @@
import 'dart:math';
import 'package:async/async.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:flutter/widgets.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/pages/chat_list.dart';
import 'package:fluffychat/widgets/connection_status_header.dart';
@ -9,6 +14,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:vrouter/vrouter.dart';
import '../../widgets/matrix.dart';
import '../../utils/account_bundles.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import '../../utils/stream_extension.dart';
@ -17,6 +23,59 @@ class ChatListView extends StatelessWidget {
const ChatListView(this.controller, {Key key}) : super(key: key);
List<BottomNavigationBarItem> getBottomBarItems(BuildContext context) {
final displayClients = Matrix.of(context).currentBundle;
if (displayClients.isEmpty) {
displayClients.addAll(Matrix.of(context).widget.clients);
controller.resetActiveBundle();
}
final items = displayClients.map((client) {
return BottomNavigationBarItem(
label: client.userID,
icon: FutureBuilder<Profile>(
future: client.ownProfile,
builder: (context, snapshot) {
return InkWell(
borderRadius: BorderRadius.circular(32),
onTap: () => controller.setActiveClient(client),
onLongPress: () =>
controller.editBundlesForAccount(client.userID),
child: Avatar(
snapshot.data?.avatarUrl,
snapshot.data?.displayName ?? client.userID.localpart,
size: 32,
),
);
}),
);
}).toList();
if (controller.displayBundles && false) {
items.insert(
0,
BottomNavigationBarItem(
label: 'Bundles',
icon: PopupMenuButton(
icon: Icon(
Icons.menu,
color: Theme.of(context).textTheme.bodyText1.color,
),
onSelected: controller.setActiveBundle,
itemBuilder: (context) => Matrix.of(context)
.accountBundles
.keys
.map(
(bundle) => PopupMenuItem(
value: bundle,
child: Text(bundle),
),
)
.toList(),
)));
}
return items;
}
@override
Widget build(BuildContext context) {
return StreamBuilder<Object>(
@ -216,12 +275,98 @@ class ChatListView extends StatelessWidget {
child: Icon(CupertinoIcons.chat_bubble),
)
: null,
bottomNavigationBar: Matrix.of(context).isMultiAccount
? StreamBuilder(
stream: StreamGroup.merge(Matrix.of(context)
.widget
.clients
.map((client) => client.onSync.stream.where((s) =>
s.accountData != null &&
s.accountData
.any((e) => e.type == accountBundlesType)))),
builder: (context, _) => Material(
color: Theme.of(context)
.bottomNavigationBarTheme
.backgroundColor,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Divider(height: 1),
Builder(builder: (context) {
final items = getBottomBarItems(context);
if (items.length == 1) {
return Padding(
padding: const EdgeInsets.all(7.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
items.single.icon,
Text(items.single.label),
],
),
);
}
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: SizedBox(
width: max(
FluffyThemes.isColumnMode(context)
? FluffyThemes.columnWidth
: MediaQuery.of(context).size.width,
Matrix.of(context).widget.clients.length *
84.0,
),
child: BottomNavigationBar(
elevation: 0,
onTap: (i) => controller.setActiveClient(
Matrix.of(context).currentBundle[i]),
currentIndex: Matrix.of(context)
.currentBundle
.indexWhere(
(client) =>
client ==
Matrix.of(context).client,
),
showUnselectedLabels: false,
showSelectedLabels: true,
type: BottomNavigationBarType.shifting,
selectedItemColor:
Theme.of(context).primaryColor,
items: items,
),
),
);
}),
if (controller.displayBundles)
Padding(
padding: const EdgeInsets.symmetric(
vertical: 4.0,
horizontal: 12,
),
child: SizedBox(
width: double.infinity,
child: CupertinoSlidingSegmentedControl(
groupValue: controller.secureActiveBundle,
onValueChanged: controller.setActiveBundle,
children: Map.fromEntries(Matrix.of(context)
.accountBundles
.keys
.map((bundle) =>
MapEntry(bundle, Text(bundle)))),
),
),
),
],
),
),
)
: null,
drawer: controller.spaces.isEmpty
? null
: Drawer(
child: SafeArea(
child: ListView.builder(
itemCount: controller.spaces.length + 1,
itemCount: controller.spaces.length,
itemBuilder: (context, i) {
if (i == 0) {
return ListTile(

View File

@ -37,9 +37,10 @@ class ChatView extends StatelessWidget {
@override
Widget build(BuildContext context) {
controller.matrix = Matrix.of(context);
controller.matrix ??= Matrix.of(context);
final client = controller.matrix.client;
controller.room ??= client.getRoomById(controller.roomId);
controller.sendingClient ??= client;
controller.room = controller.sendingClient.getRoomById(controller.roomId);
if (controller.room == null) {
return Scaffold(
appBar: AppBar(
@ -147,10 +148,7 @@ class ChatView extends StatelessWidget {
: Text(controller.selectedEvents.length.toString()),
actions: controller.selectMode
? <Widget>[
if (controller.selectedEvents.length == 1 &&
controller.selectedEvents.first.status > 0 &&
controller.selectedEvents.first.senderId ==
client.userID)
if (controller.canEditSelectedEvents)
IconButton(
icon: Icon(Icons.edit_outlined),
tooltip: L10n.of(context).edit,
@ -680,6 +678,14 @@ class ChatView extends StatelessWidget {
alignment: Alignment.center,
child: EncryptionButton(controller.room),
),
if (controller.matrix.isMultiAccount &&
controller.matrix.currentBundle.length >
1)
Container(
height: 56,
alignment: Alignment.center,
child: _ChatAccountPicker(controller),
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(
@ -792,3 +798,58 @@ class _EditContent extends StatelessWidget {
);
}
}
class _ChatAccountPicker extends StatelessWidget {
final ChatController controller;
const _ChatAccountPicker(this.controller, {Key key}) : super(key: key);
void _popupMenuButtonSelected(String mxid) {
final client = controller.matrix.currentBundle
.firstWhere((cl) => cl.userID == mxid, orElse: () => null);
if (client == null) {
Logs().w('Attempted to switch to a non-existing client $mxid');
return;
}
controller.setSendingClient(client);
}
@override
Widget build(BuildContext context) {
controller.matrix ??= Matrix.of(context);
final clients = controller.currentRoomBundle;
return Padding(
padding: const EdgeInsets.all(8.0),
child: FutureBuilder<Profile>(
future: controller.sendingClient.ownProfile,
builder: (context, snapshot) => PopupMenuButton<String>(
onSelected: _popupMenuButtonSelected,
itemBuilder: (BuildContext context) => clients
.map((client) => PopupMenuItem<String>(
value: client.userID,
child: FutureBuilder<Profile>(
future: client.ownProfile,
builder: (context, snapshot) => ListTile(
leading: Avatar(
snapshot.data?.avatarUrl,
snapshot.data?.displayName ?? client.userID.localpart,
size: 20,
),
title:
Text(snapshot.data?.displayName ?? client.userID),
contentPadding: EdgeInsets.all(0),
),
),
))
.toList(),
child: Avatar(
snapshot.data?.avatarUrl,
snapshot.data?.displayName ??
controller.matrix.client.userID.localpart,
size: 20,
),
),
),
);
}
}

View File

@ -100,7 +100,8 @@ class HomeserverPickerView extends StatelessWidget {
imageUrl: Uri.parse(
identityProvider.icon)
.getDownloadLink(
Matrix.of(context).client)
Matrix.of(context)
.getLoginClient())
.toString(),
width: 24,
height: 24,
@ -128,7 +129,7 @@ class HomeserverPickerView extends StatelessWidget {
Expanded(
child: _LoginButton(
onPressed: () =>
VRouter.of(context).to('/login'),
VRouter.of(context).to('login'),
icon: Icon(Icons.login_outlined),
labelText: L10n.of(context).login,
),

View File

@ -19,7 +19,7 @@ class LoginView extends StatelessWidget {
elevation: 0,
title: Text(
L10n.of(context).logInTo(Matrix.of(context)
.client
.getLoginClient()
.homeserver
.toString()
.replaceFirst('https://', '')),

View File

@ -20,6 +20,13 @@ class SettingsAccountView extends StatelessWidget {
withScrolling: true,
child: Column(
children: [
ListTile(
trailing: Icon(Icons.add_box_outlined),
title: Text(L10n.of(context).addAccount),
subtitle: Text(L10n.of(context).enableMultiAccounts),
onTap: controller.addAccountAction,
),
Divider(height: 1),
ListTile(
trailing: Icon(Icons.edit_outlined),
title: Text(L10n.of(context).editDisplayname),
@ -38,6 +45,7 @@ class SettingsAccountView extends StatelessWidget {
title: Text(L10n.of(context).devices),
onTap: () => VRouter.of(context).to('devices'),
),
Divider(height: 1),
ListTile(
trailing: Icon(Icons.exit_to_app_outlined),
title: Text(L10n.of(context).logout),

View File

@ -38,7 +38,7 @@ class SignupPageView extends StatelessWidget {
labelText: L10n.of(context).username,
prefixText: '@',
suffixText:
':${Matrix.of(context).client.homeserver.host}'),
':${Matrix.of(context).getLoginClient().homeserver.host}'),
),
),
Divider(),

View File

@ -0,0 +1,99 @@
import 'package:matrix/matrix.dart';
class AccountBundles {
String prefix;
List<AccountBundle> bundles;
AccountBundles({this.prefix, this.bundles});
AccountBundles.fromJson(Map<String, dynamic> json)
: prefix = json.tryGet<String>('prefix'),
bundles = json['bundles'] is List
? json['bundles']
.map((b) {
try {
return AccountBundle.fromJson(b);
} catch (_) {
return null;
}
})
.whereType<AccountBundle>()
.toList()
: null;
Map<String, dynamic> toJson() => {
if (prefix != null) 'prefix': prefix,
if (bundles != null) 'bundles': bundles.map((v) => v.toJson()).toList(),
};
}
class AccountBundle {
String name;
int priority;
AccountBundle({this.name, this.priority});
AccountBundle.fromJson(Map<String, dynamic> json)
: name = json.tryGet<String>('name'),
priority = json.tryGet<int>('priority');
Map<String, dynamic> toJson() => <String, dynamic>{
if (name != null) 'name': name,
if (priority != null) 'priority': priority,
};
}
const accountBundlesType = 'im.fluffychat.account_bundles';
extension AccountBundlesExtension on Client {
List<AccountBundle> get accountBundles {
List<AccountBundle> ret;
if (accountData.containsKey(accountBundlesType)) {
ret = AccountBundles.fromJson(accountData[accountBundlesType].content)
.bundles;
}
ret ??= [];
if (ret.isEmpty) {
ret.add(AccountBundle(
name: userID,
priority: 0,
));
}
return ret;
}
Future<void> setAccountBundle(String name, [int priority]) async {
final data =
AccountBundles.fromJson(accountData[accountBundlesType]?.content ?? {});
var foundBundle = false;
data.bundles ??= [];
for (final bundle in data.bundles) {
if (bundle.name == name) {
bundle.priority = priority;
foundBundle = true;
break;
}
}
if (!foundBundle) {
data.bundles.add(AccountBundle(name: name, priority: priority));
}
await setAccountData(userID, accountBundlesType, data.toJson());
}
Future<void> removeFromAccountBundle(String name) async {
if (!accountData.containsKey(accountBundlesType)) {
return; // nothing to do
}
final data =
AccountBundles.fromJson(accountData[accountBundlesType].content);
if (data.bundles == null) return;
data.bundles.removeWhere((b) => b.name == name);
await setAccountData(userID, accountBundlesType, data.toJson());
}
String get sendPrefix {
final data =
AccountBundles.fromJson(accountData[accountBundlesType]?.content ?? {});
return data.prefix;
}
}

View File

@ -0,0 +1,72 @@
import 'dart:convert';
import 'package:fluffychat/utils/platform_infos.dart';
import 'package:flutter/foundation.dart';
import 'package:matrix/encryption/utils/key_verification.dart';
import 'package:matrix/matrix.dart';
import 'famedlysdk_store.dart';
import 'matrix_sdk_extensions.dart/flutter_matrix_hive_database.dart';
abstract class ClientManager {
static const String clientNamespace = 'im.fluffychat.store.clients';
static Future<List<Client>> getClients() async {
final clientNames = <String>{};
try {
final rawClientNames = await Store().getItem(clientNamespace);
if (rawClientNames != null) {
final clientNamesList =
(jsonDecode(rawClientNames) as List).cast<String>();
clientNames.addAll(clientNamesList);
}
} catch (e, s) {
Logs().w('Client names in store are corrupted', e, s);
await Store().deleteItem(clientNamespace);
}
if (clientNames.isEmpty) clientNames.add(PlatformInfos.clientName);
final clients = clientNames.map(createClient).toList();
await Future.wait(clients.map((client) => client
.init()
.catchError((e, s) => Logs().e('Unable to initialize client', e, s))));
if (clients.length > 1 && clients.any((c) => !c.isLogged())) {
final loggedOutClients = clients.where((c) => !c.isLogged()).toList();
for (final client in loggedOutClients) {
clientNames.remove(client.clientName);
clients.remove(client);
}
await Store().setItem(clientNamespace, jsonEncode(clientNames.toList()));
}
return clients;
}
static Future<void> addClientNameToStore(String clientName) async {
final clientNamesList = <String>[];
final rawClientNames = await Store().getItem(clientNamespace);
if (rawClientNames != null) {
final stored = (jsonDecode(rawClientNames) as List).cast<String>();
clientNamesList.addAll(stored);
}
clientNamesList.add(clientName);
await Store().setItem(clientNamespace, jsonEncode(clientNamesList));
}
static Client createClient(String clientName) => Client(
clientName,
enableE2eeRecovery: true,
verificationMethods: {
KeyVerificationMethod.numbers,
if (PlatformInfos.isMobile || PlatformInfos.isLinux)
KeyVerificationMethod.emoji,
},
importantStateEvents: <String>{
'im.ponies.room_emotes', // we want emotes to work properly
},
databaseBuilder: FlutterMatrixHiveStore.hiveDatabaseBuilder,
supportedLoginTypes: {
AuthenticationTypes.password,
if (PlatformInfos.isMobile || PlatformInfos.isWeb)
AuthenticationTypes.sso
},
compute: compute,
);
}

View File

@ -7,10 +7,16 @@ import 'package:flutter/material.dart';
class LoadingView extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (Matrix.of(context).loginState != null) {
if (Matrix.of(context)
.widget
.clients
.every((client) => client.loginState != null)) {
WidgetsBinding.instance.addPostFrameCallback(
(_) => VRouter.of(context).to(
Matrix.of(context).loginState == LoginState.loggedIn
Matrix.of(context)
.widget
.clients
.any((client) => client.loginState == LoginState.loggedIn)
? '/rooms'
: '/home',
queryParameters: VRouter.of(context).queryParameters,

View File

@ -1,6 +1,7 @@
import 'dart:math';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/material.dart';
class OnePageCard extends StatelessWidget {
@ -12,7 +13,8 @@ class OnePageCard extends StatelessWidget {
static num breakpoint = FluffyThemes.columnWidth * 2;
@override
Widget build(BuildContext context) {
return MediaQuery.of(context).size.width <= breakpoint
return MediaQuery.of(context).size.width <= breakpoint ||
Matrix.of(context).client.isLogged()
? child
: Container(
decoration: BoxDecoration(

View File

@ -1,25 +0,0 @@
import 'package:fluffychat/pages/views/empty_page_view.dart';
import 'package:matrix/matrix.dart';
import 'package:flutter/material.dart';
import '../matrix.dart';
class WaitForInitPage extends StatelessWidget {
final Widget page;
const WaitForInitPage(this.page, {Key key}) : super(key: key);
@override
Widget build(BuildContext context) {
if (Matrix.of(context).loginState == null) {
return StreamBuilder<LoginState>(
stream: Matrix.of(context).client.onLoginStateChanged.stream,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return EmptyPage(loading: true);
}
return page;
});
}
return page;
}
}

View File

@ -3,6 +3,7 @@ import 'dart:io';
import 'dart:convert';
import 'package:adaptive_dialog/adaptive_dialog.dart';
import 'package:fluffychat/utils/client_manager.dart';
import 'package:matrix/encryption.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions.dart/matrix_locals.dart';
@ -23,6 +24,7 @@ import '../pages/key_verification_dialog.dart';
import '../utils/platform_infos.dart';
import '../config/app_config.dart';
import '../config/setting_keys.dart';
import '../utils/account_bundles.dart';
import '../utils/background_push.dart';
import 'package:vrouter/vrouter.dart';
@ -35,7 +37,7 @@ class Matrix extends StatefulWidget {
final BuildContext context;
final Client client;
final List<Client> clients;
final Map<String, String> queryParameters;
@ -43,7 +45,7 @@ class Matrix extends StatefulWidget {
this.child,
@required this.router,
@required this.context,
@required this.client,
@required this.clients,
this.queryParameters,
Key key,
}) : super(key: key);
@ -57,12 +59,98 @@ class Matrix extends StatefulWidget {
}
class MatrixState extends State<Matrix> with WidgetsBindingObserver {
Client get client => widget.client;
int activeClient = 0;
String activeBundle;
Store store = Store();
BuildContext navigatorContext;
BackgroundPush _backgroundPush;
Client get client => widget.clients[_safeActiveClient];
bool get isMultiAccount => widget.clients.length > 1;
int getClientIndexByMatrixId(String matrixId) =>
widget.clients.indexWhere((client) => client.userID == matrixId);
int get _safeActiveClient {
if (activeClient < 0 || activeClient >= widget.clients.length) {
return 0;
}
return activeClient;
}
void setActiveClient(Client cl) {
final i = widget.clients.indexWhere((c) => c == cl);
if (i != null) {
activeClient = i;
} else {
Logs().w('Tried to set an unknown client ${cl.userID} as active');
}
}
List<Client> get currentBundle {
if (!hasComplexBundles) {
return List.from(widget.clients);
}
final bundles = accountBundles;
if (bundles.containsKey(activeBundle)) {
return bundles[activeBundle];
}
return bundles.values.first;
}
Map<String, List<Client>> get accountBundles {
final resBundles = <String, List<_AccountBundleWithClient>>{};
for (var i = 0; i < widget.clients.length; i++) {
final bundles = widget.clients[i].accountBundles;
for (final bundle in bundles) {
if (bundle.name == null) {
continue;
}
resBundles[bundle.name] ??= [];
resBundles[bundle.name].add(_AccountBundleWithClient(
client: widget.clients[i],
bundle: bundle,
));
}
}
for (final b in resBundles.values) {
b.sort((a, b) => a.bundle.priority == null
? 1
: b.bundle.priority == null
? -1
: a.bundle.priority.compareTo(b.bundle.priority));
}
return resBundles
.map((k, v) => MapEntry(k, v.map((vv) => vv.client).toList()));
}
bool get hasComplexBundles => accountBundles.values.any((v) => v.length > 1);
Client _loginClientCandidate;
Client getLoginClient() {
final multiAccount = client.isLogged();
if (!multiAccount) return client;
_loginClientCandidate ??= ClientManager.createClient(
client.generateUniqueTransactionId())
..onLoginStateChanged
.stream
.where((l) => l == LoginState.loggedIn)
.first
.then((_) {
widget.clients.add(_loginClientCandidate);
ClientManager.addClientNameToStore(_loginClientCandidate.clientName);
_registerSubs(_loginClientCandidate.clientName);
widget.router.currentState.to('/rooms');
});
return _loginClientCandidate;
}
Client getClientByName(String name) => widget.clients
.firstWhere((c) => c.clientName == name, orElse: () => null);
Map<String, dynamic> get shareContent => _shareContent;
set shareContent(Map<String, dynamic> content) {
_shareContent = content;
@ -78,8 +166,8 @@ class MatrixState extends State<Matrix> with WidgetsBindingObserver {
void _initWithStore() async {
try {
await client.init();
if (client.isLogged()) {
// TODO: Figure out how this works in multi account
final statusMsg = await store.getItem(SettingKeys.ownStatusMessage);
if (statusMsg?.isNotEmpty ?? false) {
Logs().v('Send cached status message: "$statusMsg"');
@ -97,15 +185,15 @@ class MatrixState extends State<Matrix> with WidgetsBindingObserver {
}
}
StreamSubscription onRoomKeyRequestSub;
StreamSubscription onKeyVerificationRequestSub;
StreamSubscription onJitsiCallSub;
StreamSubscription onNotification;
StreamSubscription<LoginState> onLoginStateChanged;
StreamSubscription<UiaRequest> onUiaRequest;
final onRoomKeyRequestSub = <String, StreamSubscription>{};
final onKeyVerificationRequestSub = <String, StreamSubscription>{};
final onJitsiCallSub = <String, StreamSubscription>{};
final onNotification = <String, StreamSubscription>{};
final onLoginStateChanged = <String, StreamSubscription<LoginState>>{};
final onUiaRequest = <String, StreamSubscription<UiaRequest>>{};
StreamSubscription<html.Event> onFocusSub;
StreamSubscription<html.Event> onBlurSub;
StreamSubscription<Presence> onOwnPresence;
final onOwnPresence = <String, StreamSubscription<Presence>>{};
String _cachedPassword;
String get cachedPassword {
@ -165,10 +253,8 @@ class MatrixState extends State<Matrix> with WidgetsBindingObserver {
return uiaRequest
.cancel(Exception(L10n.of(context).serverRequiresEmail));
}
final clientSecret =
Matrix.of(context).client.generateUniqueTransactionId();
final currentThreepidCreds =
await Matrix.of(context).client.requestTokenToRegisterEmail(
final clientSecret = client.generateUniqueTransactionId();
final currentThreepidCreds = await client.requestTokenToRegisterEmail(
clientSecret,
emailInput.single,
0,
@ -289,21 +375,14 @@ class MatrixState extends State<Matrix> with WidgetsBindingObserver {
}
}
LoginState loginState;
void initMatrix() {
// Display the app lock
if (PlatformInfos.isMobile) {
WidgetsBinding.instance.addPostFrameCallback((_) {
FlutterSecureStorage().read(key: SettingKeys.appLockKey).then((lock) {
if (lock?.isNotEmpty ?? false) {
AppLock.of(widget.context).enable();
AppLock.of(widget.context).showLockScreen();
void _registerSubs(String name) {
final c = getClientByName(name);
if (c == null) {
Logs().w(
'Attempted to register subscriptions for non-existing client $name');
return;
}
});
});
}
onKeyVerificationRequestSub ??= client.onKeyVerificationRequest.stream
onKeyVerificationRequestSub[name] ??= c.onKeyVerificationRequest.stream
.listen((KeyVerification request) async {
var hidPopup = false;
request.onUpdate = () {
@ -334,51 +413,95 @@ class MatrixState extends State<Matrix> with WidgetsBindingObserver {
await request.rejectVerification();
}
});
_initWithStore();
if (kIsWeb) {
onFocusSub = html.window.onFocus.listen((_) => webHasFocus = true);
onBlurSub = html.window.onBlur.listen((_) => webHasFocus = false);
onLoginStateChanged[name] ??= c.onLoginStateChanged.stream.listen((state) {
final loggedInWithMultipleClients = widget.clients.length > 1;
if (state != LoginState.loggedIn) {
_cancelSubs(c.clientName);
widget.clients.remove(c);
}
onLoginStateChanged ??= client.onLoginStateChanged.stream.listen((state) {
if (loginState != state) {
loginState = state;
final isInLoginRoutes = {'/home', '/login', '/signup'}
.contains(widget.router.currentState.url);
if (widget.router.currentState.url == '/' ||
(state == LoginState.loggedIn) == isInLoginRoutes) {
if (loggedInWithMultipleClients) {
// TODO: display a nicer toast
showOkAlertDialog(
useRootNavigator: false,
context: navigatorContext,
title: 'Login state of client $name changed',
message: 'New login state: $state',
okLabel: L10n.of(widget.context).ok,
);
if (state != LoginState.loggedIn) {
widget.router.currentState.to(
loginState == LoginState.loggedIn ? '/rooms' : '/home',
'/rooms',
queryParameters: widget.router.currentState.queryParameters,
);
}
} else {
widget.router.currentState.to(
state == LoginState.loggedIn ? '/rooms' : '/home',
queryParameters: widget.router.currentState.queryParameters,
);
}
});
// Cache and resend status message
onOwnPresence ??= client.onPresence.stream.listen((presence) {
if (client.isLogged() &&
client.userID == presence.senderId &&
onOwnPresence[name] ??= c.onPresence.stream.listen((presence) {
if (c.isLogged() &&
c.userID == presence.senderId &&
presence.presence?.statusMsg != null) {
Logs().v('Update status message: "${presence.presence.statusMsg}"');
store.setItem(
SettingKeys.ownStatusMessage, presence.presence.statusMsg);
}
});
onUiaRequest ??= client.onUiaRequest.stream.listen(_onUiaRequest);
onUiaRequest[name] ??= c.onUiaRequest.stream.listen(_onUiaRequest);
if (PlatformInfos.isWeb || PlatformInfos.isLinux) {
client.onSync.stream.first.then((s) {
c.onSync.stream.first.then((s) {
html.Notification.requestPermission();
onNotification ??= client.onEvent.stream
onNotification[name] ??= c.onEvent.stream
.where((e) =>
e.type == EventUpdateType.timeline &&
[EventTypes.Message, EventTypes.Sticker, EventTypes.Encrypted]
.contains(e.content['type']) &&
e.content['sender'] != client.userID)
e.content['sender'] != c.userID)
.listen(_showLocalNotification);
});
}
}
void _cancelSubs(String name) {
onRoomKeyRequestSub[name]?.cancel();
onRoomKeyRequestSub.remove(name);
onKeyVerificationRequestSub[name]?.cancel();
onKeyVerificationRequestSub.remove(name);
onLoginStateChanged[name]?.cancel();
onLoginStateChanged.remove(name);
onOwnPresence[name]?.cancel();
onOwnPresence.remove(name);
onNotification[name]?.cancel();
onNotification.remove(name);
}
void initMatrix() {
// Display the app lock
if (PlatformInfos.isMobile) {
WidgetsBinding.instance.addPostFrameCallback((_) {
FlutterSecureStorage().read(key: SettingKeys.appLockKey).then((lock) {
if (lock?.isNotEmpty ?? false) {
AppLock.of(widget.context).enable();
AppLock.of(widget.context).showLockScreen();
}
});
});
}
_initWithStore();
for (final c in widget.clients) {
_registerSubs(c.clientName);
}
if (kIsWeb) {
onFocusSub = html.window.onFocus.listen((_) => webHasFocus = true);
onBlurSub = html.window.onBlur.listen((_) => webHasFocus = false);
}
if (PlatformInfos.isMobile) {
_backgroundPush = BackgroundPush(
@ -445,11 +568,12 @@ class MatrixState extends State<Matrix> with WidgetsBindingObserver {
void dispose() {
WidgetsBinding.instance.removeObserver(this);
onRoomKeyRequestSub?.cancel();
onKeyVerificationRequestSub?.cancel();
onLoginStateChanged?.cancel();
onOwnPresence?.cancel();
onNotification?.cancel();
onRoomKeyRequestSub.values.map((s) => s.cancel());
onKeyVerificationRequestSub.values.map((s) => s.cancel());
onLoginStateChanged.values.map((s) => s.cancel());
onOwnPresence.values.map((s) => s.cancel());
onNotification.values.map((s) => s.cancel());
onFocusSub?.cancel();
onBlurSub?.cancel();
_backgroundPush?.onLogin?.cancel();
@ -491,3 +615,9 @@ class FixedThreepidCreds extends ThreepidCreds {
return data;
}
}
class _AccountBundleWithClient {
final Client client;
final AccountBundle bundle;
_AccountBundleWithClient({this.client, this.bundle});
}

View File

@ -9,6 +9,7 @@ import audioplayers
import file_selector_macos
import firebase_core
import flutter_local_notifications
import geolocator_apple
import package_info_plus_macos
import path_provider_macos
import share_plus_macos
@ -22,6 +23,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
FLTPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FLTPackageInfoPlusPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))

View File

@ -23,6 +23,8 @@ PODS:
- FMDB (2.7.5):
- FMDB/standard (= 2.7.5)
- FMDB/standard (2.7.5)
- geolocator_apple (1.2.0):
- FlutterMacOS
- GoogleDataTransport (8.4.0):
- GoogleUtilities/Environment (~> 7.2)
- nanopb (~> 2.30908.0)
@ -59,6 +61,7 @@ DEPENDENCIES:
- firebase_core (from `Flutter/ephemeral/.symlinks/plugins/firebase_core/macos`)
- flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`)
- FlutterMacOS (from `Flutter/ephemeral`)
- geolocator_apple (from `Flutter/ephemeral/.symlinks/plugins/geolocator_apple/macos`)
- package_info_plus_macos (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus_macos/macos`)
- path_provider_macos (from `Flutter/ephemeral/.symlinks/plugins/path_provider_macos/macos`)
- share_plus_macos (from `Flutter/ephemeral/.symlinks/plugins/share_plus_macos/macos`)
@ -89,6 +92,8 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos
FlutterMacOS:
:path: Flutter/ephemeral
geolocator_apple:
:path: Flutter/ephemeral/.symlinks/plugins/geolocator_apple/macos
package_info_plus_macos:
:path: Flutter/ephemeral/.symlinks/plugins/package_info_plus_macos/macos
path_provider_macos:
@ -114,6 +119,7 @@ SPEC CHECKSUMS:
flutter_local_notifications: 3805ca215b2fb7f397d78b66db91f6a747af52e4
FlutterMacOS: 57701585bf7de1b3fc2bb61f6378d73bbdea8424
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
geolocator_apple: 821be05bbdb1b49500e029ebcbf2d6acf2dfb966
GoogleDataTransport: cd9db2180fcecd8da1b561aea31e3e56cf834aa7
GoogleUtilities: 284cddc7fffc14ae1907efb6f78ab95c1fccaedc
nanopb: a0ba3315591a9ae0a16a309ee504766e90db0c96
@ -128,4 +134,4 @@ SPEC CHECKSUMS:
PODFILE CHECKSUM: c7161fcf45d4fd9025dc0f48a76d6e64e52f8176
COCOAPODS: 1.10.1
COCOAPODS: 1.11.0

View File

@ -269,6 +269,7 @@
"${BUILT_PRODUCTS_DIR}/audioplayers/audioplayers.framework",
"${BUILT_PRODUCTS_DIR}/file_selector_macos/file_selector_macos.framework",
"${BUILT_PRODUCTS_DIR}/flutter_local_notifications/flutter_local_notifications.framework",
"${BUILT_PRODUCTS_DIR}/geolocator_apple/geolocator_apple.framework",
"${BUILT_PRODUCTS_DIR}/nanopb/nanopb.framework",
"${BUILT_PRODUCTS_DIR}/package_info_plus_macos/package_info_plus_macos.framework",
"${BUILT_PRODUCTS_DIR}/path_provider_macos/path_provider_macos.framework",
@ -289,6 +290,7 @@
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/audioplayers.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/file_selector_macos.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/flutter_local_notifications.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/geolocator_apple.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/nanopb.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/package_info_plus_macos.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/path_provider_macos.framework",