contact invitation accept notifications

This commit is contained in:
Christien Rioux 2024-07-25 14:37:51 -04:00
parent 6080c2f0c6
commit 1455aabe6c
27 changed files with 718 additions and 220 deletions

View File

@ -153,6 +153,10 @@
"invalid_pin": "Invalid PIN",
"invalid_password": "Invalid password"
},
"waiting_invitation": {
"accepted": "Contact invitation accepted from {name}",
"reject": "Contact invitation was rejected"
},
"paste_invitation_dialog": {
"title": "Paste Contact Invite",
"paste_invite_here": "Paste your contact invite here:",

View File

@ -11,6 +11,7 @@ import '../../chat_list/chat_list.dart';
import '../../contact_invitation/contact_invitation.dart';
import '../../contacts/contacts.dart';
import '../../conversation/conversation.dart';
import '../../notifications/notifications.dart';
import '../../proto/proto.dart' as proto;
import '../account_manager.dart';
@ -146,6 +147,7 @@ class PerAccountCollectionCubit extends Cubit<PerAccountCollectionState> {
accountRecordCubit!,
contactInvitationListCubit,
contactListCubit,
_locator<NotificationsCubit>(),
));
// ActiveChatCubit
@ -262,13 +264,15 @@ class PerAccountCollectionCubit extends Cubit<PerAccountCollectionState> {
AccountInfo,
AccountRecordCubit,
ContactInvitationListCubit,
ContactListCubit
ContactListCubit,
NotificationsCubit,
)>(
create: (params) => WaitingInvitationsBlocMapCubit(
accountInfo: params.$1,
accountRecordCubit: params.$2,
contactInvitationListCubit: params.$3,
contactListCubit: params.$4,
notificationsCubit: params.$5,
));
final activeChatCubitUpdater =
BlocUpdater<ActiveChatCubit, bool>(create: (_) => ActiveChatCubit(null));

View File

@ -11,6 +11,7 @@ import 'package:protobuf/protobuf.dart';
import 'package:veilid_support/veilid_support.dart';
import '../../layout/default_app_bar.dart';
import '../../notifications/notifications.dart';
import '../../proto/proto.dart' as proto;
import '../../theme/theme.dart';
import '../../tools/tools.dart';
@ -106,12 +107,14 @@ class _EditAccountPageState extends WindowSetupState<EditAccountPage> {
final success = await AccountRepository.instance.deleteLocalAccount(
widget.superIdentityRecordKey, widget.accountRecord);
if (success && mounted) {
showInfoToast(
context, translate('edit_account_page.account_removed'));
context
.read<NotificationsCubit>()
.info(text: translate('edit_account_page.account_removed'));
GoRouterHelper(context).pop();
} else if (mounted) {
showErrorToast(
context, translate('edit_account_page.failed_to_remove'));
context
.read<NotificationsCubit>()
.error(text: translate('edit_account_page.failed_to_remove'));
}
} finally {
if (mounted) {
@ -172,12 +175,14 @@ class _EditAccountPageState extends WindowSetupState<EditAccountPage> {
final success = await AccountRepository.instance.destroyAccount(
widget.superIdentityRecordKey, widget.accountRecord);
if (success && mounted) {
showInfoToast(
context, translate('edit_account_page.account_destroyed'));
context
.read<NotificationsCubit>()
.info(text: translate('edit_account_page.account_destroyed'));
GoRouterHelper(context).pop();
} else if (mounted) {
showErrorToast(
context, translate('edit_account_page.failed_to_destroy'));
context
.read<NotificationsCubit>()
.error(text: translate('edit_account_page.failed_to_destroy'));
}
} finally {
if (mounted) {

View File

@ -1,5 +1,6 @@
import 'package:animated_theme_switcher/animated_theme_switcher.dart';
import 'package:async_tools/async_tools.dart';
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@ -13,6 +14,7 @@ import 'package:veilid_support/veilid_support.dart';
import 'account_manager/account_manager.dart';
import 'init.dart';
import 'layout/splash.dart';
import 'notifications/notifications.dart';
import 'router/router.dart';
import 'settings/settings.dart';
import 'theme/theme.dart';
@ -24,8 +26,8 @@ class ReloadThemeIntent extends Intent {
const ReloadThemeIntent();
}
class AttachDetachThemeIntent extends Intent {
const AttachDetachThemeIntent();
class AttachDetachIntent extends Intent {
const AttachDetachIntent();
}
class VeilidChatApp extends StatelessWidget {
@ -55,7 +57,7 @@ class VeilidChatApp extends StatelessWidget {
});
}
void _attachDetachTheme(BuildContext context) {
void _attachDetach(BuildContext context) {
singleFuture(this, () async {
if (ProcessorRepository.instance.processorConnectionState.isAttached) {
log.info('Detaching');
@ -77,14 +79,13 @@ class VeilidChatApp extends StatelessWidget {
const ReloadThemeIntent(),
LogicalKeySet(
LogicalKeyboardKey.alt, LogicalKeyboardKey.keyD):
const AttachDetachThemeIntent(),
const AttachDetachIntent(),
},
child: Actions(actions: <Type, Action<Intent>>{
ReloadThemeIntent: CallbackAction<ReloadThemeIntent>(
onInvoke: (intent) => _reloadTheme(context)),
AttachDetachThemeIntent:
CallbackAction<AttachDetachThemeIntent>(
onInvoke: (intent) => _attachDetachTheme(context)),
AttachDetachIntent: CallbackAction<AttachDetachIntent>(
onInvoke: (intent) => _attachDetach(context)),
}, child: Focus(autofocus: true, child: builder(context)))));
@override
@ -101,10 +102,17 @@ class VeilidChatApp extends StatelessWidget {
final localizationDelegate = LocalizedApp.of(context).delegate;
return ThemeProvider(
initTheme: initialThemeData,
builder: (_, theme) => LocalizationProvider(
builder: (context, theme) => LocalizationProvider(
state: LocalizationProvider.of(context).state,
child: MultiBlocProvider(
providers: [
BlocProvider<PreferencesCubit>(
create: (context) =>
PreferencesCubit(PreferencesRepository.instance),
),
BlocProvider<NotificationsCubit>(
create: (context) => NotificationsCubit(
const NotificationsState(queue: IList.empty()))),
BlocProvider<ConnectionStateCubit>(
create: (context) =>
ConnectionStateCubit(ProcessorRepository.instance)),
@ -124,10 +132,6 @@ class VeilidChatApp extends StatelessWidget {
create: (context) =>
ActiveLocalAccountCubit(AccountRepository.instance),
),
BlocProvider<PreferencesCubit>(
create: (context) =>
PreferencesCubit(PreferencesRepository.instance),
),
BlocProvider<PerAccountCollectionBlocMapCubit>(
create: (context) => PerAccountCollectionBlocMapCubit(
accountRepository: AccountRepository.instance,

View File

@ -13,6 +13,7 @@ import 'package:veilid_support/veilid_support.dart';
import '../../account_manager/account_manager.dart';
import '../../contacts/contacts.dart';
import '../../conversation/conversation.dart';
import '../../notifications/notifications.dart';
import '../../theme/theme.dart';
import '../chat.dart';
@ -27,10 +28,10 @@ class ChatComponentWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scale = theme.extension<ScaleScheme>()!;
final scaleConfig = theme.extension<ScaleConfig>()!;
final textTheme = theme.textTheme;
// final theme = Theme.of(context);
// final scale = theme.extension<ScaleScheme>()!;
// final scaleConfig = theme.extension<ScaleConfig>()!;
// final textTheme = theme.textTheme;
// Get the account info
final accountInfo = context.watch<AccountInfoCubit>().state;
@ -221,14 +222,15 @@ class ChatComponentWidget extends StatelessWidget {
onSendPressed: (pt) {
try {
if (!messageIsValid) {
showErrorToast(context,
translate('chat.message_too_long'));
context.read<NotificationsCubit>().error(
text:
translate('chat.message_too_long'));
return;
}
_handleSendPressed(chatComponentCubit, pt);
} on FormatException {
showErrorToast(context,
translate('chat.message_too_long'));
context.read<NotificationsCubit>().error(
text: translate('chat.message_too_long'));
}
},
listBottomWidget: messageIsValid
@ -267,8 +269,11 @@ class ChatComponentWidget extends StatelessWidget {
ChatComponentCubit chatComponentCubit,
WindowState<types.Message> messageWindow,
ScrollNotification notification) async {
print(
'_handlePageForward: messagesState.length=${messageWindow.length} messagesState.windowTail=${messageWindow.windowTail} messagesState.windowCount=${messageWindow.windowCount} ScrollNotification=$notification');
debugPrint(
'_handlePageForward: messagesState.length=${messageWindow.length} '
'messagesState.windowTail=${messageWindow.windowTail} '
'messagesState.windowCount=${messageWindow.windowCount} '
'ScrollNotification=$notification');
// Go forward a page
final tail = min(messageWindow.length,
@ -299,8 +304,11 @@ class ChatComponentWidget extends StatelessWidget {
WindowState<types.Message> messageWindow,
ScrollNotification notification,
) async {
print(
'_handlePageBackward: messagesState.length=${messageWindow.length} messagesState.windowTail=${messageWindow.windowTail} messagesState.windowCount=${messageWindow.windowCount} ScrollNotification=$notification');
debugPrint(
'_handlePageBackward: messagesState.length=${messageWindow.length} '
'messagesState.windowTail=${messageWindow.windowTail} '
'messagesState.windowCount=${messageWindow.windowCount} '
'ScrollNotification=$notification');
// Go back a page
final tail = max(

View File

@ -54,7 +54,7 @@ class ContactInvitationListCubit
return dhtRecord;
}
Future<Uint8List> createInvitation(
Future<(Uint8List, TypedKey)> createInvitation(
{required proto.Profile profile,
required EncryptionKeyType encryptionKeyType,
required String encryptionKey,
@ -82,6 +82,7 @@ class ContactInvitationListCubit
// to and it will be eventually encrypted with the DH of the contact's
// identity key
late final Uint8List signedContactInvitationBytes;
late final TypedKey contactRequestInboxKey;
await (await pool.createRecord(
debugName: 'ContactInvitationListCubit::createInvitation::'
'LocalConversation',
@ -119,6 +120,9 @@ class ContactInvitationListCubit
]),
crypto: const VeilidCryptoPublic()))
.deleteScope((contactRequestInbox) async {
// Keep the contact request inbox key
contactRequestInboxKey = contactRequestInbox.key;
// Store ContactRequest in owner subkey
await contactRequestInbox.eventualWriteProtobuf(creq);
// Store an empty invitation response
@ -158,7 +162,7 @@ class ContactInvitationListCubit
});
});
return signedContactInvitationBytes;
return (signedContactInvitationBytes, contactRequestInboxKey);
}
Future<void> deleteInvitation(

View File

@ -1,8 +1,9 @@
import 'dart:typed_data';
import 'package:bloc_advanced_tools/bloc_advanced_tools.dart';
import 'package:veilid_support/veilid_support.dart';
class InvitationGeneratorCubit extends FutureCubit<Uint8List> {
class InvitationGeneratorCubit extends FutureCubit<(Uint8List, TypedKey)> {
InvitationGeneratorCubit(super.fut);
InvitationGeneratorCubit.value(super.v) : super.value();
}

View File

@ -1,9 +1,11 @@
import 'package:async_tools/async_tools.dart';
import 'package:bloc_advanced_tools/bloc_advanced_tools.dart';
import 'package:flutter_translate/flutter_translate.dart';
import 'package:veilid_support/veilid_support.dart';
import '../../account_manager/account_manager.dart';
import '../../contacts/contacts.dart';
import '../../notifications/notifications.dart';
import '../../proto/proto.dart' as proto;
import 'cubits.dart';
@ -22,11 +24,13 @@ class WaitingInvitationsBlocMapCubit extends BlocMapCubit<TypedKey,
{required AccountInfo accountInfo,
required AccountRecordCubit accountRecordCubit,
required ContactInvitationListCubit contactInvitationListCubit,
required ContactListCubit contactListCubit})
required ContactListCubit contactListCubit,
required NotificationsCubit notificationsCubit})
: _accountInfo = accountInfo,
_accountRecordCubit = accountRecordCubit,
_contactInvitationListCubit = contactInvitationListCubit,
_contactListCubit = contactListCubit {
_contactListCubit = contactListCubit,
_notificationsCubit = notificationsCubit {
// React to invitation status changes
_singleInvitationStatusProcessor.follow(
stream, state, _invitationStatusListener);
@ -81,11 +85,22 @@ class WaitingInvitationsBlocMapCubit extends BlocMapCubit<TypedKey,
localConversationRecordKey:
acceptedContact.localConversationRecordKey,
);
// Notify about acceptance
_notificationsCubit.info(
text: translate('waiting_invitation.accepted',
args: {'name': acceptedContact.remoteProfile.name}));
} else {
// Reject
await _contactInvitationListCubit.deleteInvitation(
accepted: false,
contactRequestInboxRecordKey: contactRequestInboxRecordKey);
// Notify about rejection
_notificationsCubit.info(
text: translate(
'waiting_invitation.rejected',
));
}
}
}
@ -108,6 +123,7 @@ class WaitingInvitationsBlocMapCubit extends BlocMapCubit<TypedKey,
final AccountRecordCubit _accountRecordCubit;
final ContactInvitationListCubit _contactInvitationListCubit;
final ContactListCubit _contactListCubit;
final NotificationsCubit _notificationsCubit;
final _singleInvitationStatusProcessor =
SingleStateProcessor<WaitingInvitationsBlocMapState>();
}

View File

@ -7,19 +7,22 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_translate/flutter_translate.dart';
import 'package:provider/provider.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:veilid_support/veilid_support.dart';
import '../../notifications/notifications.dart';
import '../../proto/proto.dart' as proto;
import '../../theme/theme.dart';
import '../contact_invitation.dart';
class ContactInvitationDisplayDialog extends StatelessWidget {
const ContactInvitationDisplayDialog._({
required this.modalContext,
required this.locator,
required this.message,
});
final BuildContext modalContext;
final Locator locator;
final String message;
@override
@ -27,7 +30,7 @@ class ContactInvitationDisplayDialog extends StatelessWidget {
super.debugFillProperties(properties);
properties
..add(StringProperty('message', message))
..add(DiagnosticsProperty<BuildContext>('modalContext', modalContext));
..add(DiagnosticsProperty<Locator>('locator', locator));
}
String makeTextInvite(String message, Uint8List data) {
@ -48,72 +51,87 @@ class ContactInvitationDisplayDialog extends StatelessWidget {
final textTheme = theme.textTheme;
final scaleConfig = theme.extension<ScaleConfig>()!;
final signedContactInvitationBytesV =
context.watch<InvitationGeneratorCubit>().state;
final generatorOutputV = context.watch<InvitationGeneratorCubit>().state;
final cardsize =
min<double>(MediaQuery.of(context).size.shortestSide - 48.0, 400);
return PopControl(
dismissible: !signedContactInvitationBytesV.isLoading,
child: Dialog(
shape: RoundedRectangleBorder(
side: const BorderSide(width: 2),
borderRadius:
BorderRadius.circular(16 * scaleConfig.borderRadiusScale)),
backgroundColor: Colors.white,
child: ConstrainedBox(
constraints: BoxConstraints(
minWidth: cardsize,
maxWidth: cardsize,
minHeight: cardsize,
maxHeight: cardsize),
child: signedContactInvitationBytesV.when(
loading: buildProgressIndicator,
data: (data) => Column(children: [
FittedBox(
child: Text(
translate(
'create_invitation_dialog.contact_invitation'),
style: textTheme.headlineSmall!
.copyWith(color: Colors.black)))
.paddingAll(8),
FittedBox(
child: QrImageView.withQr(
size: 300,
qr: QrCode.fromUint8List(
data: data,
errorCorrectLevel:
QrErrorCorrectLevel.L)))
.expanded(),
Text(message,
softWrap: true,
style: textTheme.labelLarge!
.copyWith(color: Colors.black))
.paddingAll(8),
ElevatedButton.icon(
icon: const Icon(Icons.copy),
style: ElevatedButton.styleFrom(
foregroundColor: Colors.black,
backgroundColor: Colors.white,
side: const BorderSide()),
label: Text(translate(
'create_invitation_dialog.copy_invitation')),
onPressed: () async {
showInfoToast(
context,
translate(
'create_invitation_dialog.invitation_copied'));
await Clipboard.setData(ClipboardData(
text: makeTextInvite(message, data)));
},
).paddingAll(16),
]),
error: errorPage))));
return BlocListener<ContactInvitationListCubit,
ContactInvitiationListState>(
bloc: locator<ContactInvitationListCubit>(),
listener: (context, state) {
final listState = state.state.asData?.value;
final data = generatorOutputV.asData?.value;
if (listState != null && data != null) {
final idx = listState.indexWhere((x) =>
x.value.contactRequestInbox.recordKey.toVeilid() == data.$2);
if (idx == -1) {
// This invitation is gone, close it
Navigator.pop(context);
}
}
},
child: PopControl(
dismissible: !generatorOutputV.isLoading,
child: Dialog(
shape: RoundedRectangleBorder(
side: const BorderSide(width: 2),
borderRadius: BorderRadius.circular(
16 * scaleConfig.borderRadiusScale)),
backgroundColor: Colors.white,
child: ConstrainedBox(
constraints: BoxConstraints(
minWidth: cardsize,
maxWidth: cardsize,
minHeight: cardsize,
maxHeight: cardsize),
child: generatorOutputV.when(
loading: buildProgressIndicator,
data: (data) => Column(children: [
FittedBox(
child: Text(
translate('create_invitation_dialog'
'.contact_invitation'),
style: textTheme.headlineSmall!
.copyWith(color: Colors.black)))
.paddingAll(8),
FittedBox(
child: QrImageView.withQr(
size: 300,
qr: QrCode.fromUint8List(
data: data.$1,
errorCorrectLevel:
QrErrorCorrectLevel.L)))
.expanded(),
Text(message,
softWrap: true,
style: textTheme.labelLarge!
.copyWith(color: Colors.black))
.paddingAll(8),
ElevatedButton.icon(
icon: const Icon(Icons.copy),
style: ElevatedButton.styleFrom(
foregroundColor: Colors.black,
backgroundColor: Colors.white,
side: const BorderSide()),
label: Text(translate(
'create_invitation_dialog.copy_invitation')),
onPressed: () async {
context.read<NotificationsCubit>().info(
text: translate('create_invitation_dialog'
'.invitation_copied'));
await Clipboard.setData(ClipboardData(
text: makeTextInvite(message, data.$1)));
},
).paddingAll(16),
]),
error: errorPage)))));
}
static Future<void> show(
{required BuildContext context,
required Locator locator,
required InvitationGeneratorCubit Function(BuildContext) create,
required String message}) async {
await showPopControlDialog<void>(
@ -121,7 +139,7 @@ class ContactInvitationDisplayDialog extends StatelessWidget {
builder: (context) => BlocProvider(
create: create,
child: ContactInvitationDisplayDialog._(
modalContext: context,
locator: locator,
message: message,
)));
}

View File

@ -52,9 +52,13 @@ class ContactInvitationItemWidget extends StatelessWidget {
}
await ContactInvitationDisplayDialog.show(
context: context,
locator: context.read,
message: contactInvitationRecord.message,
create: (context) => InvitationGeneratorCubit.value(
Uint8List.fromList(contactInvitationRecord.invitation)));
create: (context) => InvitationGeneratorCubit.value((
Uint8List.fromList(contactInvitationRecord.invitation),
contactInvitationRecord.contactRequestInbox.recordKey
.toVeilid()
)));
},
endActions: [
SliderTileAction(

View File

@ -5,16 +5,17 @@ import 'package:awesome_extensions/awesome_extensions.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_translate/flutter_translate.dart';
import 'package:provider/provider.dart';
import 'package:veilid_support/veilid_support.dart';
import '../../account_manager/account_manager.dart';
import '../../notifications/notifications.dart';
import '../../theme/theme.dart';
import '../contact_invitation.dart';
class CreateInvitationDialog extends StatefulWidget {
const CreateInvitationDialog._({required this.modalContext});
const CreateInvitationDialog._({required this.locator});
@override
CreateInvitationDialogState createState() => CreateInvitationDialogState();
@ -23,16 +24,15 @@ class CreateInvitationDialog extends StatefulWidget {
await StyledDialog.show<void>(
context: context,
title: translate('create_invitation_dialog.title'),
child: CreateInvitationDialog._(modalContext: context));
child: CreateInvitationDialog._(locator: context.read));
}
final BuildContext modalContext;
final Locator locator;
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties
.add(DiagnosticsProperty<BuildContext>('modalContext', modalContext));
properties.add(DiagnosticsProperty<Locator>('locator', locator));
}
}
@ -86,8 +86,8 @@ class CreateInvitationDialogState extends State<CreateInvitationDialog> {
if (!mounted) {
return;
}
showErrorToast(
context, translate('create_invitation_dialog.pin_does_not_match'));
context.read<NotificationsCubit>().error(
text: translate('create_invitation_dialog.pin_does_not_match'));
setState(() {
_encryptionKeyType = EncryptionKeyType.none;
_encryptionKey = '';
@ -124,8 +124,8 @@ class CreateInvitationDialogState extends State<CreateInvitationDialog> {
if (!mounted) {
return;
}
showErrorToast(context,
translate('create_invitation_dialog.password_does_not_match'));
context.read<NotificationsCubit>().error(
text: translate('create_invitation_dialog.password_does_not_match'));
setState(() {
_encryptionKeyType = EncryptionKeyType.none;
_encryptionKey = '';
@ -138,13 +138,9 @@ class CreateInvitationDialogState extends State<CreateInvitationDialog> {
// Start generation
final contactInvitationListCubit =
widget.modalContext.read<ContactInvitationListCubit>();
final profile = widget.modalContext
.read<AccountRecordCubit>()
.state
.asData
?.value
.profile;
widget.locator<ContactInvitationListCubit>();
final profile =
widget.locator<AccountRecordCubit>().state.asData?.value.profile;
if (profile == null) {
return;
}
@ -158,6 +154,7 @@ class CreateInvitationDialogState extends State<CreateInvitationDialog> {
await ContactInvitationDisplayDialog.show(
context: context,
locator: widget.locator,
message: _messageTextController.text,
create: (context) => InvitationGeneratorCubit(generator));

View File

@ -9,6 +9,7 @@ import 'package:veilid_support/veilid_support.dart';
import '../../account_manager/account_manager.dart';
import '../../contacts/contacts.dart';
import '../../notifications/notifications.dart';
import '../../theme/theme.dart';
import '../../tools/tools.dart';
import '../contact_invitation.dart';
@ -102,7 +103,9 @@ class InvitationDialogState extends State<InvitationDialog> {
}
} else {
if (mounted) {
showErrorToast(context, 'invitation_dialog.failed_to_accept');
context
.read<NotificationsCubit>()
.error(text: 'invitation_dialog.failed_to_accept');
}
}
}
@ -124,7 +127,9 @@ class InvitationDialogState extends State<InvitationDialog> {
// do nothing right now
} else {
if (mounted) {
showErrorToast(context, 'invitation_dialog.failed_to_reject');
context
.read<NotificationsCubit>()
.error(text: 'invitation_dialog.failed_to_reject');
}
}
}
@ -218,7 +223,7 @@ class InvitationDialogState extends State<InvitationDialog> {
errorText = translate('invitation_dialog.invalid_password');
}
if (mounted) {
showErrorToast(context, errorText);
context.read<NotificationsCubit>().error(text: errorText);
}
setState(() {
_isValidating = false;
@ -233,7 +238,7 @@ class InvitationDialogState extends State<InvitationDialog> {
errorText = translate('invitation_dialog.invalid_invitation');
}
if (mounted) {
showErrorToast(context, errorText);
context.read<NotificationsCubit>().error(text: errorText);
}
setState(() {
_isValidating = false;

View File

@ -12,6 +12,7 @@ import 'package:pasteboard/pasteboard.dart';
import 'package:provider/provider.dart';
import 'package:zxing2/qrcode.dart';
import '../../notifications/notifications.dart';
import '../../theme/theme.dart';
import 'invitation_dialog.dart';
@ -269,13 +270,18 @@ class ScanInvitationDialogState extends State<ScanInvitationDialog> {
));
} on MobileScannerException catch (e) {
if (e.errorCode == MobileScannerErrorCode.permissionDenied) {
showErrorToast(
context, translate('scan_invitation_dialog.permission_error'));
context
.read<NotificationsCubit>()
.error(text: translate('scan_invitation_dialog.permission_error'));
} else {
showErrorToast(context, translate('scan_invitation_dialog.error'));
context
.read<NotificationsCubit>()
.error(text: translate('scan_invitation_dialog.error'));
}
} on Exception catch (_) {
showErrorToast(context, translate('scan_invitation_dialog.error'));
context
.read<NotificationsCubit>()
.error(text: translate('scan_invitation_dialog.error'));
}
return null;
@ -285,8 +291,9 @@ class ScanInvitationDialogState extends State<ScanInvitationDialog> {
final imageBytes = await Pasteboard.image;
if (imageBytes == null) {
if (context.mounted) {
showErrorToast(
context, translate('scan_invitation_dialog.not_an_image'));
context
.read<NotificationsCubit>()
.error(text: translate('scan_invitation_dialog.not_an_image'));
}
return null;
}
@ -294,8 +301,8 @@ class ScanInvitationDialogState extends State<ScanInvitationDialog> {
final image = img.decodeImage(imageBytes);
if (image == null) {
if (context.mounted) {
showErrorToast(context,
translate('scan_invitation_dialog.could_not_decode_image'));
context.read<NotificationsCubit>().error(
text: translate('scan_invitation_dialog.could_not_decode_image'));
}
return null;
}
@ -319,8 +326,8 @@ class ScanInvitationDialogState extends State<ScanInvitationDialog> {
return Uint8List.fromList(segs[0].toList());
} on Exception catch (_) {
if (context.mounted) {
showErrorToast(
context, translate('scan_invitation_dialog.not_a_valid_qr_code'));
context.read<NotificationsCubit>().error(
text: translate('scan_invitation_dialog.not_a_valid_qr_code'));
}
return null;
}

View File

@ -1,9 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:flutter_translate/flutter_translate.dart';
import 'package:quickalert/quickalert.dart';
import 'package:radix_colors/radix_colors.dart';
import '../tools/tools.dart';

View File

@ -0,0 +1,26 @@
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../notifications.dart';
class NotificationsCubit extends Cubit<NotificationsState> {
NotificationsCubit(super.initialState);
void info({required String text, String? title}) {
emit(state.copyWith(
queue: state.queue.add(NotificationItem(
type: NotificationType.info, text: text, title: title))));
}
void error({required String text, String? title}) {
emit(state.copyWith(
queue: state.queue.add(NotificationItem(
type: NotificationType.info, text: text, title: title))));
}
IList<NotificationItem> popAll() {
final out = state.queue;
emit(state.copyWith(queue: state.queue.clear()));
return out;
}
}

View File

@ -0,0 +1,23 @@
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'notifications_state.freezed.dart';
enum NotificationType {
info,
error,
}
@freezed
class NotificationItem with _$NotificationItem {
const factory NotificationItem(
{required NotificationType type,
required String text,
String? title}) = _NotificationItem;
}
@freezed
class NotificationsState with _$NotificationsState {
const factory NotificationsState({required IList<NotificationItem> queue}) =
_NotificationsState;
}

View File

@ -0,0 +1,290 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'notifications_state.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models');
/// @nodoc
mixin _$NotificationItem {
NotificationType get type => throw _privateConstructorUsedError;
String get text => throw _privateConstructorUsedError;
String? get title => throw _privateConstructorUsedError;
@JsonKey(ignore: true)
$NotificationItemCopyWith<NotificationItem> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $NotificationItemCopyWith<$Res> {
factory $NotificationItemCopyWith(
NotificationItem value, $Res Function(NotificationItem) then) =
_$NotificationItemCopyWithImpl<$Res, NotificationItem>;
@useResult
$Res call({NotificationType type, String text, String? title});
}
/// @nodoc
class _$NotificationItemCopyWithImpl<$Res, $Val extends NotificationItem>
implements $NotificationItemCopyWith<$Res> {
_$NotificationItemCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
@pragma('vm:prefer-inline')
@override
$Res call({
Object? type = null,
Object? text = null,
Object? title = freezed,
}) {
return _then(_value.copyWith(
type: null == type
? _value.type
: type // ignore: cast_nullable_to_non_nullable
as NotificationType,
text: null == text
? _value.text
: text // ignore: cast_nullable_to_non_nullable
as String,
title: freezed == title
? _value.title
: title // ignore: cast_nullable_to_non_nullable
as String?,
) as $Val);
}
}
/// @nodoc
abstract class _$$NotificationItemImplCopyWith<$Res>
implements $NotificationItemCopyWith<$Res> {
factory _$$NotificationItemImplCopyWith(_$NotificationItemImpl value,
$Res Function(_$NotificationItemImpl) then) =
__$$NotificationItemImplCopyWithImpl<$Res>;
@override
@useResult
$Res call({NotificationType type, String text, String? title});
}
/// @nodoc
class __$$NotificationItemImplCopyWithImpl<$Res>
extends _$NotificationItemCopyWithImpl<$Res, _$NotificationItemImpl>
implements _$$NotificationItemImplCopyWith<$Res> {
__$$NotificationItemImplCopyWithImpl(_$NotificationItemImpl _value,
$Res Function(_$NotificationItemImpl) _then)
: super(_value, _then);
@pragma('vm:prefer-inline')
@override
$Res call({
Object? type = null,
Object? text = null,
Object? title = freezed,
}) {
return _then(_$NotificationItemImpl(
type: null == type
? _value.type
: type // ignore: cast_nullable_to_non_nullable
as NotificationType,
text: null == text
? _value.text
: text // ignore: cast_nullable_to_non_nullable
as String,
title: freezed == title
? _value.title
: title // ignore: cast_nullable_to_non_nullable
as String?,
));
}
}
/// @nodoc
class _$NotificationItemImpl implements _NotificationItem {
const _$NotificationItemImpl(
{required this.type, required this.text, this.title});
@override
final NotificationType type;
@override
final String text;
@override
final String? title;
@override
String toString() {
return 'NotificationItem(type: $type, text: $text, title: $title)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$NotificationItemImpl &&
(identical(other.type, type) || other.type == type) &&
(identical(other.text, text) || other.text == text) &&
(identical(other.title, title) || other.title == title));
}
@override
int get hashCode => Object.hash(runtimeType, type, text, title);
@JsonKey(ignore: true)
@override
@pragma('vm:prefer-inline')
_$$NotificationItemImplCopyWith<_$NotificationItemImpl> get copyWith =>
__$$NotificationItemImplCopyWithImpl<_$NotificationItemImpl>(
this, _$identity);
}
abstract class _NotificationItem implements NotificationItem {
const factory _NotificationItem(
{required final NotificationType type,
required final String text,
final String? title}) = _$NotificationItemImpl;
@override
NotificationType get type;
@override
String get text;
@override
String? get title;
@override
@JsonKey(ignore: true)
_$$NotificationItemImplCopyWith<_$NotificationItemImpl> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
mixin _$NotificationsState {
IList<NotificationItem> get queue => throw _privateConstructorUsedError;
@JsonKey(ignore: true)
$NotificationsStateCopyWith<NotificationsState> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $NotificationsStateCopyWith<$Res> {
factory $NotificationsStateCopyWith(
NotificationsState value, $Res Function(NotificationsState) then) =
_$NotificationsStateCopyWithImpl<$Res, NotificationsState>;
@useResult
$Res call({IList<NotificationItem> queue});
}
/// @nodoc
class _$NotificationsStateCopyWithImpl<$Res, $Val extends NotificationsState>
implements $NotificationsStateCopyWith<$Res> {
_$NotificationsStateCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
@pragma('vm:prefer-inline')
@override
$Res call({
Object? queue = null,
}) {
return _then(_value.copyWith(
queue: null == queue
? _value.queue
: queue // ignore: cast_nullable_to_non_nullable
as IList<NotificationItem>,
) as $Val);
}
}
/// @nodoc
abstract class _$$NotificationsStateImplCopyWith<$Res>
implements $NotificationsStateCopyWith<$Res> {
factory _$$NotificationsStateImplCopyWith(_$NotificationsStateImpl value,
$Res Function(_$NotificationsStateImpl) then) =
__$$NotificationsStateImplCopyWithImpl<$Res>;
@override
@useResult
$Res call({IList<NotificationItem> queue});
}
/// @nodoc
class __$$NotificationsStateImplCopyWithImpl<$Res>
extends _$NotificationsStateCopyWithImpl<$Res, _$NotificationsStateImpl>
implements _$$NotificationsStateImplCopyWith<$Res> {
__$$NotificationsStateImplCopyWithImpl(_$NotificationsStateImpl _value,
$Res Function(_$NotificationsStateImpl) _then)
: super(_value, _then);
@pragma('vm:prefer-inline')
@override
$Res call({
Object? queue = null,
}) {
return _then(_$NotificationsStateImpl(
queue: null == queue
? _value.queue
: queue // ignore: cast_nullable_to_non_nullable
as IList<NotificationItem>,
));
}
}
/// @nodoc
class _$NotificationsStateImpl implements _NotificationsState {
const _$NotificationsStateImpl({required this.queue});
@override
final IList<NotificationItem> queue;
@override
String toString() {
return 'NotificationsState(queue: $queue)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$NotificationsStateImpl &&
const DeepCollectionEquality().equals(other.queue, queue));
}
@override
int get hashCode =>
Object.hash(runtimeType, const DeepCollectionEquality().hash(queue));
@JsonKey(ignore: true)
@override
@pragma('vm:prefer-inline')
_$$NotificationsStateImplCopyWith<_$NotificationsStateImpl> get copyWith =>
__$$NotificationsStateImplCopyWithImpl<_$NotificationsStateImpl>(
this, _$identity);
}
abstract class _NotificationsState implements NotificationsState {
const factory _NotificationsState(
{required final IList<NotificationItem> queue}) =
_$NotificationsStateImpl;
@override
IList<NotificationItem> get queue;
@override
@JsonKey(ignore: true)
_$$NotificationsStateImplCopyWith<_$NotificationsStateImpl> get copyWith =>
throw _privateConstructorUsedError;
}

View File

@ -0,0 +1,3 @@
export 'cubits/notifications_cubit.dart';
export 'models/notifications_state.dart';
export 'views/notifications_widget.dart';

View File

@ -0,0 +1,91 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:motion_toast/motion_toast.dart';
import '../../theme/theme.dart';
import '../notifications.dart';
class NotificationsWidget extends StatelessWidget {
const NotificationsWidget({required Widget child, super.key})
: _child = child;
////////////////////////////////////////////////////////////////////////////
// Public API
@override
Widget build(BuildContext context) {
final notificationsCubit = context.read<NotificationsCubit>();
return BlocListener<NotificationsCubit, NotificationsState>(
bloc: notificationsCubit,
listener: (context, state) {
if (state.queue.isNotEmpty) {
final queue = notificationsCubit.popAll();
for (final notificationItem in queue) {
switch (notificationItem.type) {
case NotificationType.info:
_info(
context: context,
text: notificationItem.text,
title: notificationItem.title);
case NotificationType.error:
_error(
context: context,
text: notificationItem.text,
title: notificationItem.title);
}
}
}
},
child: _child);
}
////////////////////////////////////////////////////////////////////////////
// Private Implementation
void _info(
{required BuildContext context, required String text, String? title}) {
final theme = Theme.of(context);
final scale = theme.extension<ScaleScheme>()!;
final scaleConfig = theme.extension<ScaleConfig>()!;
MotionToast(
title: title != null ? Text(title) : null,
description: Text(text),
constraints: BoxConstraints.loose(const Size(400, 100)),
contentPadding: const EdgeInsets.all(16),
primaryColor: scale.tertiaryScale.elementBackground,
secondaryColor: scale.tertiaryScale.calloutBackground,
borderRadius: 12 * scaleConfig.borderRadiusScale,
toastDuration: const Duration(seconds: 2),
animationDuration: const Duration(milliseconds: 500),
displayBorder: scaleConfig.useVisualIndicators,
icon: Icons.info,
).show(context);
}
void _error(
{required BuildContext context, required String text, String? title}) {
final theme = Theme.of(context);
final scale = theme.extension<ScaleScheme>()!;
final scaleConfig = theme.extension<ScaleConfig>()!;
MotionToast(
title: title != null ? Text(title) : null,
description: Text(text),
constraints: BoxConstraints.loose(const Size(400, 100)),
contentPadding: const EdgeInsets.all(16),
primaryColor: scale.errorScale.elementBackground,
secondaryColor: scale.errorScale.calloutBackground,
borderRadius: 12 * scaleConfig.borderRadiusScale,
toastDuration: const Duration(seconds: 4),
animationDuration: const Duration(milliseconds: 1000),
displayBorder: scaleConfig.useVisualIndicators,
icon: Icons.error,
).show(context);
}
////////////////////////////////////////////////////////////////////////////
final Widget _child;
}

View File

@ -15,6 +15,7 @@ import '../../proto/proto.dart' as proto;
import '../../settings/settings.dart';
import '../../tools/tools.dart';
import '../../veilid_processor/views/developer.dart';
import '../views/router_shell.dart';
part 'router_cubit.freezed.dart';
part 'router_cubit.g.dart';
@ -58,42 +59,47 @@ class RouterCubit extends Cubit<RouterState> {
/// Our application routes
List<RouteBase> get routes => [
GoRoute(
path: '/',
builder: (context, state) => const HomeScreen(),
),
GoRoute(
path: '/edit_account',
builder: (context, state) {
final extra = state.extra! as List<Object?>;
return EditAccountPage(
superIdentityRecordKey: extra[0]! as TypedKey,
existingProfile: extra[1]! as proto.Profile,
accountRecord: extra[2]! as OwnedDHTRecordPointer,
);
},
),
GoRoute(
path: '/new_account',
builder: (context, state) => const NewAccountPage(),
),
GoRoute(
path: '/new_account/recovery_key',
builder: (context, state) {
final extra = state.extra! as List<Object?>;
ShellRoute(
builder: (context, state, child) => RouterShell(child: child),
routes: [
GoRoute(
path: '/',
builder: (context, state) => const HomeScreen(),
),
GoRoute(
path: '/edit_account',
builder: (context, state) {
final extra = state.extra! as List<Object?>;
return EditAccountPage(
superIdentityRecordKey: extra[0]! as TypedKey,
existingProfile: extra[1]! as proto.Profile,
accountRecord: extra[2]! as OwnedDHTRecordPointer,
);
},
),
GoRoute(
path: '/new_account',
builder: (context, state) => const NewAccountPage(),
),
GoRoute(
path: '/new_account/recovery_key',
builder: (context, state) {
final extra = state.extra! as List<Object?>;
return ShowRecoveryKeyPage(
writableSuperIdentity: extra[0]! as WritableSuperIdentity,
name: extra[1]! as String);
}),
GoRoute(
path: '/settings',
builder: (context, state) => const SettingsPage(),
),
GoRoute(
path: '/developer',
builder: (context, state) => const DeveloperPage(),
)
return ShowRecoveryKeyPage(
writableSuperIdentity:
extra[0]! as WritableSuperIdentity,
name: extra[1]! as String);
}),
GoRoute(
path: '/settings',
builder: (context, state) => const SettingsPage(),
),
GoRoute(
path: '/developer',
builder: (context, state) => const DeveloperPage(),
)
])
];
/// Redirects when our state changes

View File

@ -1 +1,2 @@
export 'cubit/router_cubit.dart';
export 'cubits/router_cubit.dart';
export 'views/router_shell.dart';

View File

@ -0,0 +1,12 @@
import 'package:flutter/widgets.dart';
import '../../notifications/notifications.dart';
class RouterShell extends StatelessWidget {
const RouterShell({required Widget child, super.key}) : _child = child;
@override
Widget build(BuildContext context) => NotificationsWidget(child: _child);
final Widget _child;
}

View File

@ -8,7 +8,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_spinkit/flutter_spinkit.dart';
import 'package:flutter_sticky_header/flutter_sticky_header.dart';
import 'package:motion_toast/motion_toast.dart';
import 'package:quickalert/quickalert.dart';
import 'package:sliver_expandable/sliver_expandable.dart';
@ -132,46 +131,6 @@ Future<void> showErrorModal(
);
}
void showErrorToast(BuildContext context, String message) {
final theme = Theme.of(context);
final scale = theme.extension<ScaleScheme>()!;
final scaleConfig = theme.extension<ScaleConfig>()!;
MotionToast(
//title: Text(translate('toast.error')),
description: Text(message),
constraints: BoxConstraints.loose(const Size(400, 100)),
contentPadding: const EdgeInsets.all(16),
primaryColor: scale.errorScale.elementBackground,
secondaryColor: scale.errorScale.calloutBackground,
borderRadius: 12 * scaleConfig.borderRadiusScale,
toastDuration: const Duration(seconds: 4),
animationDuration: const Duration(milliseconds: 1000),
displayBorder: scaleConfig.useVisualIndicators,
icon: Icons.error,
).show(context);
}
void showInfoToast(BuildContext context, String message) {
final theme = Theme.of(context);
final scale = theme.extension<ScaleScheme>()!;
final scaleConfig = theme.extension<ScaleConfig>()!;
MotionToast(
//title: Text(translate('toast.info')),
description: Text(message),
constraints: BoxConstraints.loose(const Size(400, 100)),
contentPadding: const EdgeInsets.all(16),
primaryColor: scale.tertiaryScale.elementBackground,
secondaryColor: scale.tertiaryScale.calloutBackground,
borderRadius: 12 * scaleConfig.borderRadiusScale,
toastDuration: const Duration(seconds: 2),
animationDuration: const Duration(milliseconds: 500),
displayBorder: scaleConfig.useVisualIndicators,
icon: Icons.info,
).show(context);
}
SliverAppBar styledSliverAppBar(
{required BuildContext context, required String title, Color? titleColor}) {
final theme = Theme.of(context);

View File

@ -8,6 +8,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_translate/flutter_translate.dart';
import 'package:go_router/go_router.dart';
import 'package:loggy/loggy.dart';
@ -16,6 +17,7 @@ import 'package:veilid_support/veilid_support.dart';
import 'package:xterm/xterm.dart';
import '../../layout/layout.dart';
import '../../notifications/notifications.dart';
import '../../theme/theme.dart';
import '../../tools/tools.dart';
import 'history_text_editing_controller.dart';
@ -133,7 +135,9 @@ class _DeveloperPageState extends State<DeveloperPage> {
Future<void> clear(BuildContext context) async {
globalDebugTerminal.buffer.clear();
if (context.mounted) {
showInfoToast(context, translate('developer.cleared'));
context
.read<NotificationsCubit>()
.info(text: translate('developer.cleared'));
}
}
@ -144,7 +148,9 @@ class _DeveloperPageState extends State<DeveloperPage> {
_terminalController.clearSelection();
await Clipboard.setData(ClipboardData(text: text));
if (context.mounted) {
showInfoToast(context, translate('developer.copied'));
context
.read<NotificationsCubit>()
.info(text: translate('developer.copied'));
}
}
}
@ -153,7 +159,9 @@ class _DeveloperPageState extends State<DeveloperPage> {
final text = globalDebugTerminal.buffer.getText();
await Clipboard.setData(ClipboardData(text: text));
if (context.mounted) {
showInfoToast(context, translate('developer.copied_all'));
context
.read<NotificationsCubit>()
.info(text: translate('developer.copied_all'));
}
}

View File

@ -25,7 +25,9 @@ mixin _$IdentityInstance {
throw _privateConstructorUsedError; // Public key of identity instance
FixedEncodedString43 get publicKey =>
throw _privateConstructorUsedError; // Secret key of identity instance
// Encrypted with DH(publicKey, SuperIdentity.secret) with appended salt
// Encrypted with appended salt, key is DeriveSharedSecret(
// password = SuperIdentity.secret,
// salt = publicKey)
// Used to recover accounts without generating a new instance
@Uint8ListJsonConverter()
Uint8List get encryptedSecretKey =>
@ -179,7 +181,9 @@ class _$IdentityInstanceImpl extends _IdentityInstance {
@override
final FixedEncodedString43 publicKey;
// Secret key of identity instance
// Encrypted with DH(publicKey, SuperIdentity.secret) with appended salt
// Encrypted with appended salt, key is DeriveSharedSecret(
// password = SuperIdentity.secret,
// salt = publicKey)
// Used to recover accounts without generating a new instance
@override
@Uint8ListJsonConverter()
@ -257,7 +261,9 @@ abstract class _IdentityInstance extends IdentityInstance {
@override // Public key of identity instance
FixedEncodedString43 get publicKey;
@override // Secret key of identity instance
// Encrypted with DH(publicKey, SuperIdentity.secret) with appended salt
// Encrypted with appended salt, key is DeriveSharedSecret(
// password = SuperIdentity.secret,
// salt = publicKey)
// Used to recover accounts without generating a new instance
@Uint8ListJsonConverter()
Uint8List get encryptedSecretKey;