more refactor

This commit is contained in:
Christien Rioux 2024-01-09 20:58:27 -05:00
parent b83aa3a64b
commit c7b541c643
45 changed files with 860 additions and 336 deletions

View file

@ -0,0 +1 @@
export 'views/views.dart';

View file

@ -0,0 +1,131 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:awesome_extensions/awesome_extensions.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_translate/flutter_translate.dart';
import '../old_to_refactor/tools/tools.dart';
import '../old_to_refactor/veilid_support/veilid_support.dart';
import 'views/invite_dialog.dart';
class PasteInviteDialog extends ConsumerStatefulWidget {
const PasteInviteDialog({super.key});
@override
PasteInviteDialogState createState() => PasteInviteDialogState();
static Future<void> show(BuildContext context) async {
await showStyledDialog<void>(
context: context,
title: translate('paste_invite_dialog.title'),
child: const PasteInviteDialog());
}
}
class PasteInviteDialogState extends ConsumerState<PasteInviteDialog> {
final _pasteTextController = TextEditingController();
@override
void initState() {
super.initState();
}
Future<void> _onPasteChanged(
String text,
Future<void> Function({
required Uint8List inviteData,
}) validateInviteData) async {
final lines = text.split('\n');
if (lines.isEmpty) {
return;
}
var firstline =
lines.indexWhere((element) => element.contains('BEGIN VEILIDCHAT'));
firstline += 1;
var lastline =
lines.indexWhere((element) => element.contains('END VEILIDCHAT'));
if (lastline == -1) {
lastline = lines.length;
}
if (lastline <= firstline) {
return;
}
final inviteDataBase64 = lines
.sublist(firstline, lastline)
.join()
.replaceAll(RegExp(r'[^A-Za-z0-9\-_]'), '');
final inviteData = base64UrlNoPadDecode(inviteDataBase64);
await validateInviteData(inviteData: inviteData);
}
void onValidationCancelled() {
_pasteTextController.clear();
}
void onValidationSuccess() {
//_pasteTextController.clear();
}
void onValidationFailed() {
_pasteTextController.clear();
}
bool inviteControlIsValid() => _pasteTextController.text.isNotEmpty;
Widget buildInviteControl(
BuildContext context,
InviteDialogState dialogState,
Future<void> Function({required Uint8List inviteData})
validateInviteData) {
final theme = Theme.of(context);
final scale = theme.extension<ScaleScheme>()!;
//final textTheme = theme.textTheme;
//final height = MediaQuery.of(context).size.height;
final monoStyle = TextStyle(
fontFamily: 'Source Code Pro',
fontSize: 11,
color: scale.primaryScale.text,
);
return Column(mainAxisSize: MainAxisSize.min, children: [
Text(
translate('paste_invite_dialog.paste_invite_here'),
).paddingLTRB(0, 0, 0, 8),
Container(
constraints: const BoxConstraints(maxHeight: 200),
child: TextField(
enabled: !dialogState.isValidating,
onChanged: (text) async =>
_onPasteChanged(text, validateInviteData),
style: monoStyle,
keyboardType: TextInputType.multiline,
maxLines: null,
controller: _pasteTextController,
decoration: const InputDecoration(
border: OutlineInputBorder(),
hintText: '--- BEGIN VEILIDCHAT CONTACT INVITE ----\n'
'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX\n'
'---- END VEILIDCHAT CONTACT INVITE -----\n',
//labelText: translate('paste_invite_dialog.paste')
),
)).paddingLTRB(0, 0, 0, 8)
]);
}
@override
// ignore: prefer_expression_function_bodies
Widget build(BuildContext context) {
return InviteDialog(
onValidationCancelled: onValidationCancelled,
onValidationSuccess: onValidationSuccess,
onValidationFailed: onValidationFailed,
inviteControlIsValid: inviteControlIsValid,
buildInviteControl: buildInviteControl);
}
}

View file

@ -0,0 +1,399 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'package:awesome_extensions/awesome_extensions.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_translate/flutter_translate.dart';
import 'package:image/image.dart' as img;
import 'package:mobile_scanner/mobile_scanner.dart';
import 'package:pasteboard/pasteboard.dart';
import 'package:zxing2/qrcode.dart';
import '../old_to_refactor/tools/tools.dart';
import 'views/invite_dialog.dart';
class BarcodeOverlay extends CustomPainter {
BarcodeOverlay({
required this.barcode,
required this.arguments,
required this.boxFit,
required this.capture,
});
final BarcodeCapture capture;
final Barcode barcode;
final MobileScannerArguments arguments;
final BoxFit boxFit;
@override
void paint(Canvas canvas, Size size) {
if (barcode.corners == null) {
return;
}
final adjustedSize = applyBoxFit(boxFit, arguments.size, size);
var verticalPadding = size.height - adjustedSize.destination.height;
var horizontalPadding = size.width - adjustedSize.destination.width;
if (verticalPadding > 0) {
verticalPadding = verticalPadding / 2;
} else {
verticalPadding = 0;
}
if (horizontalPadding > 0) {
horizontalPadding = horizontalPadding / 2;
} else {
horizontalPadding = 0;
}
final ratioWidth =
(Platform.isIOS ? capture.width! : arguments.size.width) /
adjustedSize.destination.width;
final ratioHeight =
(Platform.isIOS ? capture.height! : arguments.size.height) /
adjustedSize.destination.height;
final adjustedOffset = <Offset>[];
for (final offset in barcode.corners!) {
adjustedOffset.add(
Offset(
offset.dx / ratioWidth + horizontalPadding,
offset.dy / ratioHeight + verticalPadding,
),
);
}
final cutoutPath = Path()..addPolygon(adjustedOffset, true);
final backgroundPaint = Paint()
..color = Colors.red.withOpacity(0.3)
..style = PaintingStyle.fill
..blendMode = BlendMode.dstOut;
canvas.drawPath(cutoutPath, backgroundPaint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
class ScannerOverlay extends CustomPainter {
ScannerOverlay(this.scanWindow);
final Rect scanWindow;
@override
void paint(Canvas canvas, Size size) {
final backgroundPath = Path()..addRect(Rect.largest);
final cutoutPath = Path()..addRect(scanWindow);
final backgroundPaint = Paint()
..color = Colors.black.withOpacity(0.5)
..style = PaintingStyle.fill
..blendMode = BlendMode.dstOut;
final backgroundWithCutout = Path.combine(
PathOperation.difference,
backgroundPath,
cutoutPath,
);
canvas.drawPath(backgroundWithCutout, backgroundPaint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
class ScanInviteDialog extends ConsumerStatefulWidget {
const ScanInviteDialog({super.key});
@override
ScanInviteDialogState createState() => ScanInviteDialogState();
static Future<void> show(BuildContext context) async {
await showStyledDialog<void>(
context: context,
title: translate('scan_invite_dialog.title'),
child: const ScanInviteDialog());
}
}
class ScanInviteDialogState extends ConsumerState<ScanInviteDialog> {
bool scanned = false;
@override
void initState() {
super.initState();
}
void onValidationCancelled() {
setState(() {
scanned = false;
});
}
void onValidationSuccess() {}
void onValidationFailed() {
setState(() {
scanned = false;
});
}
bool inviteControlIsValid() => false; // _pasteTextController.text.isNotEmpty;
Future<Uint8List?> scanQRImage(BuildContext context) async {
final theme = Theme.of(context);
//final textTheme = theme.textTheme;
final scale = theme.extension<ScaleScheme>()!;
final windowSize = MediaQuery.of(context).size;
//final maxDialogWidth = min(windowSize.width - 64.0, 800.0 - 64.0);
//final maxDialogHeight = windowSize.height - 64.0;
final scanWindow = Rect.fromCenter(
center: MediaQuery.of(context).size.center(Offset.zero),
width: 200,
height: 200,
);
final cameraController = MobileScannerController();
try {
return showDialog(
context: context,
builder: (context) => Stack(
fit: StackFit.expand,
children: [
MobileScanner(
fit: BoxFit.contain,
scanWindow: scanWindow,
controller: cameraController,
errorBuilder: (context, error, child) =>
ScannerErrorWidget(error: error),
onDetect: (c) {
final barcode = c.barcodes.firstOrNull;
final barcodeBytes = barcode?.rawBytes;
if (barcodeBytes != null) {
cameraController.dispose();
Navigator.pop(context, barcodeBytes);
}
}),
CustomPaint(
painter: ScannerOverlay(scanWindow),
),
Align(
alignment: Alignment.bottomCenter,
child: Container(
alignment: Alignment.bottomCenter,
height: 100,
color: Colors.black.withOpacity(0.4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
IconButton(
color: Colors.white,
icon: ValueListenableBuilder(
valueListenable: cameraController.torchState,
builder: (context, state, child) {
switch (state) {
case TorchState.off:
return Icon(Icons.flash_off,
color:
scale.grayScale.subtleBackground);
case TorchState.on:
return Icon(Icons.flash_on,
color: scale.primaryScale.background);
}
},
),
iconSize: 32,
onPressed: cameraController.toggleTorch,
),
SizedBox(
width: windowSize.width - 120,
height: 50,
child: FittedBox(
child: Text(
translate('scan_invite_dialog.instructions'),
overflow: TextOverflow.fade,
style: Theme.of(context)
.textTheme
.labelLarge!
.copyWith(color: Colors.white),
),
),
),
IconButton(
color: Colors.white,
icon: ValueListenableBuilder(
valueListenable:
cameraController.cameraFacingState,
builder: (context, state, child) {
switch (state) {
case CameraFacing.front:
return const Icon(Icons.camera_front);
case CameraFacing.back:
return const Icon(Icons.camera_rear);
}
},
),
iconSize: 32,
onPressed: cameraController.switchCamera,
),
],
),
),
),
Align(
alignment: Alignment.topRight,
child: IconButton(
color: Colors.white,
icon: Icon(Icons.close,
color: scale.grayScale.background),
iconSize: 32,
onPressed: () => {
SchedulerBinding.instance
.addPostFrameCallback((_) {
cameraController.dispose();
Navigator.pop(context, null);
})
})),
],
));
} on MobileScannerException catch (e) {
if (e.errorCode == MobileScannerErrorCode.permissionDenied) {
showErrorToast(
context, translate('scan_invite_dialog.permission_error'));
} else {
showErrorToast(context, translate('scan_invite_dialog.error'));
}
} on Exception catch (_) {
showErrorToast(context, translate('scan_invite_dialog.error'));
}
return null;
}
Future<Uint8List?> pasteQRImage(BuildContext context) async {
final imageBytes = await Pasteboard.image;
if (imageBytes == null) {
if (context.mounted) {
showErrorToast(context, translate('scan_invite_dialog.not_an_image'));
}
return null;
}
final image = img.decodeImage(imageBytes);
if (image == null) {
if (context.mounted) {
showErrorToast(
context, translate('scan_invite_dialog.could_not_decode_image'));
}
return null;
}
try {
final source = RGBLuminanceSource(
image.width,
image.height,
image
.convert(numChannels: 4)
.getBytes(order: img.ChannelOrder.abgr)
.buffer
.asInt32List());
final bitmap = BinaryBitmap(HybridBinarizer(source));
final reader = QRCodeReader();
final result = reader.decode(bitmap);
final segs = result.resultMetadata[ResultMetadataType.byteSegments]!
as List<Int8List>;
return Uint8List.fromList(segs[0].toList());
} on Exception catch (_) {
if (context.mounted) {
showErrorToast(
context, translate('scan_invite_dialog.not_a_valid_qr_code'));
}
return null;
}
}
Widget buildInviteControl(
BuildContext context,
InviteDialogState dialogState,
Future<void> Function({required Uint8List inviteData})
validateInviteData) {
//final theme = Theme.of(context);
//final scale = theme.extension<ScaleScheme>()!;
//final textTheme = theme.textTheme;
//final height = MediaQuery.of(context).size.height;
if (isiOS || isAndroid) {
return Column(mainAxisSize: MainAxisSize.min, children: [
if (!scanned)
Text(
translate('scan_invite_dialog.scan_qr_here'),
).paddingLTRB(0, 0, 0, 8),
if (!scanned)
Container(
constraints: const BoxConstraints(maxHeight: 200),
child: ElevatedButton(
onPressed: dialogState.isValidating
? null
: () async {
final inviteData = await scanQRImage(context);
if (inviteData != null) {
setState(() {
scanned = true;
});
await validateInviteData(inviteData: inviteData);
}
},
child: Text(translate('scan_invite_dialog.scan'))),
).paddingLTRB(0, 0, 0, 8)
]);
}
return Column(mainAxisSize: MainAxisSize.min, children: [
if (!scanned)
Text(
translate('scan_invite_dialog.paste_qr_here'),
).paddingLTRB(0, 0, 0, 8),
if (!scanned)
Container(
constraints: const BoxConstraints(maxHeight: 200),
child: ElevatedButton(
onPressed: dialogState.isValidating
? null
: () async {
final inviteData = await pasteQRImage(context);
if (inviteData != null) {
await validateInviteData(inviteData: inviteData);
setState(() {
scanned = true;
});
}
},
child: Text(translate('scan_invite_dialog.paste'))),
).paddingLTRB(0, 0, 0, 8)
]);
}
@override
// ignore: prefer_expression_function_bodies
Widget build(BuildContext context) {
return InviteDialog(
onValidationCancelled: onValidationCancelled,
onValidationSuccess: onValidationSuccess,
onValidationFailed: onValidationFailed,
inviteControlIsValid: inviteControlIsValid,
buildInviteControl: buildInviteControl);
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<bool>('scanned', scanned));
}
}

View file

@ -0,0 +1,151 @@
import 'dart:async';
import 'dart:math';
import 'package:awesome_extensions/awesome_extensions.dart';
import 'package:basic_utils/basic_utils.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_translate/flutter_translate.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:veilid_support/veilid_support.dart';
import '../../tools/tools.dart';
class ContactInvitationDisplayDialog extends StatefulWidget {
const ContactInvitationDisplayDialog({
required this.name,
required this.message,
required this.generator,
super.key,
});
final String name;
final String message;
final FutureOr<Uint8List> generator;
@override
ContactInvitationDisplayDialogState createState() =>
ContactInvitationDisplayDialogState();
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties
..add(StringProperty('name', name))
..add(StringProperty('message', message))
..add(DiagnosticsProperty<FutureOr<Uint8List>?>('generator', generator));
}
}
class ContactInvitationDisplayDialogState
extends State<ContactInvitationDisplayDialog> {
final focusNode = FocusNode();
final formKey = GlobalKey<FormState>();
late final AutoDisposeFutureProvider<Uint8List?> _generateFutureProvider;
@override
void initState() {
super.initState();
_generateFutureProvider =
AutoDisposeFutureProvider<Uint8List>((ref) async => widget.generator);
}
@override
void dispose() {
focusNode.dispose();
super.dispose();
}
String makeTextInvite(String message, Uint8List data) {
final invite = StringUtils.addCharAtPosition(
base64UrlNoPadEncode(data), '\n', 40,
repeat: true);
final msg = message.isNotEmpty ? '$message\n' : '';
return '$msg'
'--- BEGIN VEILIDCHAT CONTACT INVITE ----\n'
'$invite\n'
'---- END VEILIDCHAT CONTACT INVITE -----\n';
}
@override
// ignore: prefer_expression_function_bodies
Widget build(BuildContext context) {
final theme = Theme.of(context);
//final scale = theme.extension<ScaleScheme>()!;
final textTheme = theme.textTheme;
final signedContactInvitationBytesV = ref.watch(_generateFutureProvider);
final cardsize =
min<double>(MediaQuery.of(context).size.shortestSide - 48.0, 400);
return Dialog(
backgroundColor: Colors.white,
child: ConstrainedBox(
constraints: BoxConstraints(
minWidth: cardsize,
maxWidth: cardsize,
minHeight: cardsize,
maxHeight: cardsize),
child: signedContactInvitationBytesV.when(
loading: () => buildProgressIndicator(context),
data: (data) {
if (data == null) {
Navigator.of(context).pop();
return const Text('');
}
return Form(
key: formKey,
child: Column(children: [
FittedBox(
child: Text(
translate(
'send_invite_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(widget.message,
softWrap: true,
style: textTheme.labelLarge!
.copyWith(color: Colors.black))
.paddingAll(8),
ElevatedButton.icon(
icon: const Icon(Icons.copy),
label: Text(
translate('send_invite_dialog.copy_invitation')),
onPressed: () async {
showInfoToast(
context,
translate(
'send_invite_dialog.invitation_copied'));
await Clipboard.setData(ClipboardData(
text: makeTextInvite(widget.message, data)));
},
).paddingAll(16),
]));
},
error: (e, s) {
Navigator.of(context).pop();
showErrorToast(context,
translate('send_invite_dialog.failed_to_generate'));
return const Text('');
})));
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties
..add(DiagnosticsProperty<FocusNode>('focusNode', focusNode))
..add(DiagnosticsProperty<GlobalKey<FormState>>('formKey', formKey));
}
}

View file

@ -0,0 +1,124 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:flutter_translate/flutter_translate.dart';
import '../../proto/proto.dart' as proto;
import '../../theme/theme.dart';
import 'contact_invitation_display.dart';
class ContactInvitationItemWidget extends StatelessWidget {
const ContactInvitationItemWidget(
{required this.contactInvitationRecord, super.key});
final proto.ContactInvitationRecord contactInvitationRecord;
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<proto.ContactInvitationRecord>(
'contactInvitationRecord', contactInvitationRecord));
}
@override
// ignore: prefer_expression_function_bodies
Widget build(BuildContext context) {
final theme = Theme.of(context);
//final textTheme = theme.textTheme;
final scale = theme.extension<ScaleScheme>()!;
return Container(
clipBehavior: Clip.antiAlias,
decoration: ShapeDecoration(
color: scale.tertiaryScale.subtleBorder,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
)),
child: Slidable(
// Specify a key if the Slidable is dismissible.
key: ObjectKey(contactInvitationRecord),
endActionPane: ActionPane(
// A motion is a widget used to control how the pane animates.
motion: const DrawerMotion(),
// A pane can dismiss the Slidable.
//dismissible: DismissiblePane(onDismissed: () {}),
// All actions are defined in the children parameter.
children: [
// A SlidableAction can have an icon and/or a label.
SlidableAction(
onPressed: (context) async {
final activeAccountInfo =
await ref.read(fetchActiveAccountProvider.future);
if (activeAccountInfo != null) {
await deleteContactInvitation(
accepted: false,
activeAccountInfo: activeAccountInfo,
contactInvitationRecord: contactInvitationRecord);
ref.invalidate(fetchContactInvitationRecordsProvider);
}
},
backgroundColor: scale.tertiaryScale.background,
foregroundColor: scale.tertiaryScale.text,
icon: Icons.delete,
label: translate('button.delete'),
padding: const EdgeInsets.all(2)),
],
),
// startActionPane: ActionPane(
// motion: const DrawerMotion(),
// children: [
// SlidableAction(
// // An action can be bigger than the others.
// flex: 2,
// onPressed: (context) => (),
// backgroundColor: Color(0xFF7BC043),
// foregroundColor: Colors.white,
// icon: Icons.archive,
// label: 'Archive',
// ),
// SlidableAction(
// onPressed: (context) => (),
// backgroundColor: Color(0xFF0392CF),
// foregroundColor: Colors.white,
// icon: Icons.save,
// label: 'Save',
// ),
// ],
// ),
// The child of the Slidable is what the user sees when the
// component is not dragged.
child: ListTile(
//title: Text(translate('contact_list.invitation')),
onTap: () async {
final activeAccountInfo =
await ref.read(fetchActiveAccountProvider.future);
if (activeAccountInfo != null) {
// ignore: use_build_context_synchronously
if (!context.mounted) {
return;
}
await showDialog<void>(
context: context,
builder: (context) => ContactInvitationDisplayDialog(
name: activeAccountInfo.localAccount.name,
message: contactInvitationRecord.message,
generator: Uint8List.fromList(
contactInvitationRecord.invitation),
));
}
},
title: Text(
contactInvitationRecord.message.isEmpty
? translate('contact_list.invitation')
: contactInvitationRecord.message,
softWrap: true,
),
iconColor: scale.tertiaryScale.background,
textColor: scale.tertiaryScale.text,
//Text(Timestamp.fromInt64(contactInvitationRecord.expiration) / ),
leading: const Icon(Icons.person_add))));
}
}

View file

@ -0,0 +1,80 @@
import 'package:awesome_extensions/awesome_extensions.dart';
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import '../../proto/proto.dart' as proto;
import '../../theme/theme.dart';
import 'contact_invitation_item_widget.dart';
class ContactInvitationListWidget extends StatefulWidget {
const ContactInvitationListWidget({
required this.contactInvitationRecordList,
super.key,
});
final IList<proto.ContactInvitationRecord> contactInvitationRecordList;
@override
ContactInvitationListWidgetState createState() =>
ContactInvitationListWidgetState();
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(IterableProperty<proto.ContactInvitationRecord>(
'contactInvitationRecordList', contactInvitationRecordList));
}
}
class ContactInvitationListWidgetState
extends State<ContactInvitationListWidget> {
final ScrollController _scrollController = ScrollController();
@override
// ignore: prefer_expression_function_bodies
Widget build(BuildContext context) {
final theme = Theme.of(context);
//final textTheme = theme.textTheme;
final scale = theme.extension<ScaleScheme>()!;
return Container(
width: double.infinity,
margin: const EdgeInsets.fromLTRB(4, 0, 4, 4),
decoration: ShapeDecoration(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
)),
constraints: const BoxConstraints(maxHeight: 200),
child: Container(
width: double.infinity,
decoration: ShapeDecoration(
color: scale.primaryScale.subtleBackground,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
)),
child: ListView.builder(
controller: _scrollController,
itemCount: widget.contactInvitationRecordList.length,
itemBuilder: (context, index) {
if (index < 0 ||
index >= widget.contactInvitationRecordList.length) {
return null;
}
return ContactInvitationItemWidget(
contactInvitationRecord:
widget.contactInvitationRecordList[index],
key: ObjectKey(widget.contactInvitationRecordList[index]))
.paddingLTRB(4, 2, 4, 2);
},
findChildIndexCallback: (key) {
final index = widget.contactInvitationRecordList.indexOf(
(key as ObjectKey).value! as proto.ContactInvitationRecord);
if (index == -1) {
return null;
}
return index;
},
).paddingLTRB(4, 6, 4, 6)),
);
}
}

View file

@ -0,0 +1,336 @@
import 'dart:async';
import 'package:awesome_extensions/awesome_extensions.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_translate/flutter_translate.dart';
import '../../account_manager/account_manager.dart';
import '../../tools/tools.dart';
class InviteDialog extends StatefulWidget {
const InviteDialog(
{required this.onValidationCancelled,
required this.onValidationSuccess,
required this.onValidationFailed,
required this.inviteControlIsValid,
required this.buildInviteControl,
super.key});
final void Function() onValidationCancelled;
final void Function() onValidationSuccess;
final void Function() onValidationFailed;
final bool Function() inviteControlIsValid;
final Widget Function(
BuildContext context,
InviteDialogState dialogState,
Future<void> Function({required Uint8List inviteData})
validateInviteData) buildInviteControl;
@override
InviteDialogState createState() => InviteDialogState();
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties
..add(ObjectFlagProperty<void Function()>.has(
'onValidationCancelled', onValidationCancelled))
..add(ObjectFlagProperty<void Function()>.has(
'onValidationSuccess', onValidationSuccess))
..add(ObjectFlagProperty<void Function()>.has(
'onValidationFailed', onValidationFailed))
..add(ObjectFlagProperty<void Function()>.has(
'inviteControlIsValid', inviteControlIsValid))
..add(ObjectFlagProperty<
Widget Function(
BuildContext context,
InviteDialogState dialogState,
Future<void> Function({required Uint8List inviteData})
validateInviteData)>.has(
'buildInviteControl', buildInviteControl));
}
}
class InviteDialogState extends State<InviteDialog> {
ValidContactInvitation? _validInvitation;
bool _isValidating = false;
bool _isAccepting = false;
@override
void initState() {
super.initState();
}
bool get isValidating => _isValidating;
bool get isAccepting => _isAccepting;
Future<void> _onAccept() async {
final navigator = Navigator.of(context);
setState(() {
_isAccepting = true;
});
final activeAccountInfo = await ref.read(fetchActiveAccountProvider.future);
if (activeAccountInfo == null) {
setState(() {
_isAccepting = false;
});
navigator.pop();
return;
}
final validInvitation = _validInvitation;
if (validInvitation != null) {
final acceptedContact =
await acceptContactInvitation(activeAccountInfo, validInvitation);
if (acceptedContact != null) {
// initiator when accept is received will create
// contact in the case of a 'note to self'
final isSelf =
activeAccountInfo.localAccount.identityMaster.identityPublicKey ==
acceptedContact.remoteIdentity.identityPublicKey;
if (!isSelf) {
await createContact(
activeAccountInfo: activeAccountInfo,
profile: acceptedContact.profile,
remoteIdentity: acceptedContact.remoteIdentity,
remoteConversationRecordKey:
acceptedContact.remoteConversationRecordKey,
localConversationRecordKey:
acceptedContact.localConversationRecordKey,
);
}
ref
..invalidate(fetchContactInvitationRecordsProvider)
..invalidate(fetchContactListProvider);
} else {
if (context.mounted) {
showErrorToast(context, 'invite_dialog.failed_to_accept');
}
}
}
setState(() {
_isAccepting = false;
});
navigator.pop();
}
Future<void> _onReject() async {
final navigator = Navigator.of(context);
setState(() {
_isAccepting = true;
});
final activeAccountInfo = await ref.read(fetchActiveAccountProvider.future);
if (activeAccountInfo == null) {
setState(() {
_isAccepting = false;
});
navigator.pop();
return;
}
final validInvitation = _validInvitation;
if (validInvitation != null) {
if (await rejectContactInvitation(activeAccountInfo, validInvitation)) {
// do nothing right now
} else {
if (context.mounted) {
showErrorToast(context, 'invite_dialog.failed_to_reject');
}
}
}
setState(() {
_isAccepting = false;
});
navigator.pop();
}
Future<void> _validateInviteData({
required Uint8List inviteData,
}) async {
try {
final activeAccountInfo =
await ref.read(fetchActiveAccountProvider.future);
if (activeAccountInfo == null) {
setState(() {
_isValidating = false;
_validInvitation = null;
});
return;
}
final contactInvitationRecords =
await ref.read(fetchContactInvitationRecordsProvider.future);
setState(() {
_isValidating = true;
_validInvitation = null;
});
final validatedContactInvitation = await validateContactInvitation(
activeAccountInfo: activeAccountInfo,
contactInvitationRecords: contactInvitationRecords,
inviteData: inviteData,
getEncryptionKeyCallback:
(cs, encryptionKeyType, encryptedSecret) async {
String encryptionKey;
switch (encryptionKeyType) {
case EncryptionKeyType.none:
encryptionKey = '';
case EncryptionKeyType.pin:
final description =
translate('invite_dialog.protected_with_pin');
if (!context.mounted) {
return null;
}
final pin = await showDialog<String>(
context: context,
builder: (context) => EnterPinDialog(
reenter: false, description: description));
if (pin == null) {
return null;
}
encryptionKey = pin;
case EncryptionKeyType.password:
final description =
translate('invite_dialog.protected_with_password');
if (!context.mounted) {
return null;
}
final password = await showDialog<String>(
context: context,
builder: (context) =>
EnterPasswordDialog(description: description));
if (password == null) {
return null;
}
encryptionKey = password;
}
return decryptSecretFromBytes(
secretBytes: encryptedSecret,
cryptoKind: cs.kind(),
encryptionKeyType: encryptionKeyType,
encryptionKey: encryptionKey);
});
// Check if validation was cancelled
if (validatedContactInvitation == null) {
setState(() {
_isValidating = false;
_validInvitation = null;
widget.onValidationCancelled();
});
return;
}
// Verify expiration
// xxx
setState(() {
widget.onValidationSuccess();
_isValidating = false;
_validInvitation = validatedContactInvitation;
});
} on ContactInviteInvalidKeyException catch (e) {
String errorText;
switch (e.type) {
case EncryptionKeyType.none:
errorText = translate('invite_dialog.invalid_invitation');
case EncryptionKeyType.pin:
errorText = translate('invite_dialog.invalid_pin');
case EncryptionKeyType.password:
errorText = translate('invite_dialog.invalid_password');
}
if (context.mounted) {
showErrorToast(context, errorText);
}
setState(() {
_isValidating = false;
_validInvitation = null;
widget.onValidationFailed();
});
} on Exception catch (e) {
log.debug('exception: $e', e);
setState(() {
_isValidating = false;
_validInvitation = null;
widget.onValidationFailed();
});
rethrow;
}
}
@override
// ignore: prefer_expression_function_bodies
Widget build(BuildContext context) {
// final theme = Theme.of(context);
// final scale = theme.extension<ScaleScheme>()!;
// final textTheme = theme.textTheme;
// final height = MediaQuery.of(context).size.height;
if (_isAccepting) {
return SizedBox(
height: 300,
width: 300,
child: buildProgressIndicator(context).toCenter())
.paddingAll(16);
}
return ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 400, maxWidth: 400),
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
widget.buildInviteControl(context, this, _validateInviteData),
if (_isValidating)
Column(children: [
Text(translate('invite_dialog.validating'))
.paddingLTRB(0, 0, 0, 16),
buildProgressIndicator(context).paddingAll(16),
]).toCenter(),
if (_validInvitation == null &&
!_isValidating &&
widget.inviteControlIsValid())
Column(children: [
Text(translate('invite_dialog.invalid_invitation')),
const Icon(Icons.error)
]).paddingAll(16).toCenter(),
if (_validInvitation != null && !_isValidating)
Column(children: [
Container(
constraints: const BoxConstraints(maxHeight: 64),
width: double.infinity,
child: ProfileWidget(
name: _validInvitation!
.contactRequestPrivate.profile.name,
pronouns: _validInvitation!
.contactRequestPrivate.profile.pronouns,
)).paddingLTRB(0, 0, 0, 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
ElevatedButton.icon(
icon: const Icon(Icons.check_circle),
label: Text(translate('button.accept')),
onPressed: _onAccept,
),
ElevatedButton.icon(
icon: const Icon(Icons.cancel),
label: Text(translate('button.reject')),
onPressed: _onReject,
)
],
),
])
]),
),
);
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties
..add(DiagnosticsProperty<bool>('isValidating', isValidating))
..add(DiagnosticsProperty<bool>('isAccepting', isAccepting));
}
}

View file

@ -0,0 +1,65 @@
import 'package:awesome_extensions/awesome_extensions.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_translate/flutter_translate.dart';
import '../../theme/theme.dart';
import 'paste_invite_dialog.dart';
import 'scan_invite_dialog.dart';
import 'send_invite_dialog.dart';
Widget newContactInvitationBottomSheetBuilder(BuildContext context) {
final theme = Theme.of(context);
final textTheme = theme.textTheme;
final scale = theme.extension<ScaleScheme>()!;
return KeyboardListener(
focusNode: FocusNode(),
onKeyEvent: (ke) {
if (ke.logicalKey == LogicalKeyboardKey.escape) {
Navigator.pop(context);
}
},
child: SizedBox(
height: 200,
child: Column(children: [
Text(translate('accounts_menu.invite_contact'),
style: textTheme.titleMedium)
.paddingAll(8),
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [
Column(children: [
IconButton(
onPressed: () async {
Navigator.pop(context);
await SendInviteDialog.show(context);
},
iconSize: 64,
icon: const Icon(Icons.contact_page),
color: scale.primaryScale.background),
Text(translate('accounts_menu.create_invite'))
]),
Column(children: [
IconButton(
onPressed: () async {
Navigator.pop(context);
await ScanInviteDialog.show(context);
},
iconSize: 64,
icon: const Icon(Icons.qr_code_scanner),
color: scale.primaryScale.background),
Text(translate('accounts_menu.scan_invite'))
]),
Column(children: [
IconButton(
onPressed: () async {
Navigator.pop(context);
await PasteInviteDialog.show(context);
},
iconSize: 64,
icon: const Icon(Icons.paste),
color: scale.primaryScale.background),
Text(translate('accounts_menu.paste_invite'))
])
]).expanded()
])));
}

View file

@ -0,0 +1,131 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:awesome_extensions/awesome_extensions.dart';
import 'package:flutter/material.dart';
import 'package:flutter_translate/flutter_translate.dart';
import 'package:veilid_support/veilid_support.dart';
import '../../theme/theme.dart';
import '../../tools/tools.dart';
import 'invite_dialog.dart';
class PasteInviteDialog extends StatefulWidget {
const PasteInviteDialog({super.key});
@override
PasteInviteDialogState createState() => PasteInviteDialogState();
static Future<void> show(BuildContext context) async {
await showStyledDialog<void>(
context: context,
title: translate('paste_invite_dialog.title'),
child: const PasteInviteDialog());
}
}
class PasteInviteDialogState extends State<PasteInviteDialog> {
final _pasteTextController = TextEditingController();
@override
void initState() {
super.initState();
}
Future<void> _onPasteChanged(
String text,
Future<void> Function({
required Uint8List inviteData,
}) validateInviteData) async {
final lines = text.split('\n');
if (lines.isEmpty) {
return;
}
var firstline =
lines.indexWhere((element) => element.contains('BEGIN VEILIDCHAT'));
firstline += 1;
var lastline =
lines.indexWhere((element) => element.contains('END VEILIDCHAT'));
if (lastline == -1) {
lastline = lines.length;
}
if (lastline <= firstline) {
return;
}
final inviteDataBase64 = lines
.sublist(firstline, lastline)
.join()
.replaceAll(RegExp(r'[^A-Za-z0-9\-_]'), '');
final inviteData = base64UrlNoPadDecode(inviteDataBase64);
await validateInviteData(inviteData: inviteData);
}
void onValidationCancelled() {
_pasteTextController.clear();
}
void onValidationSuccess() {
//_pasteTextController.clear();
}
void onValidationFailed() {
_pasteTextController.clear();
}
bool inviteControlIsValid() => _pasteTextController.text.isNotEmpty;
Widget buildInviteControl(
BuildContext context,
InviteDialogState dialogState,
Future<void> Function({required Uint8List inviteData})
validateInviteData) {
final theme = Theme.of(context);
final scale = theme.extension<ScaleScheme>()!;
//final textTheme = theme.textTheme;
//final height = MediaQuery.of(context).size.height;
final monoStyle = TextStyle(
fontFamily: 'Source Code Pro',
fontSize: 11,
color: scale.primaryScale.text,
);
return Column(mainAxisSize: MainAxisSize.min, children: [
Text(
translate('paste_invite_dialog.paste_invite_here'),
).paddingLTRB(0, 0, 0, 8),
Container(
constraints: const BoxConstraints(maxHeight: 200),
child: TextField(
enabled: !dialogState.isValidating,
onChanged: (text) async =>
_onPasteChanged(text, validateInviteData),
style: monoStyle,
keyboardType: TextInputType.multiline,
maxLines: null,
controller: _pasteTextController,
decoration: const InputDecoration(
border: OutlineInputBorder(),
hintText: '--- BEGIN VEILIDCHAT CONTACT INVITE ----\n'
'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX\n'
'---- END VEILIDCHAT CONTACT INVITE -----\n',
//labelText: translate('paste_invite_dialog.paste')
),
)).paddingLTRB(0, 0, 0, 8)
]);
}
@override
// ignore: prefer_expression_function_bodies
Widget build(BuildContext context) {
return InviteDialog(
onValidationCancelled: onValidationCancelled,
onValidationSuccess: onValidationSuccess,
onValidationFailed: onValidationFailed,
inviteControlIsValid: inviteControlIsValid,
buildInviteControl: buildInviteControl);
}
}

View file

@ -0,0 +1,395 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'package:awesome_extensions/awesome_extensions.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_translate/flutter_translate.dart';
import 'package:image/image.dart' as img;
import 'package:mobile_scanner/mobile_scanner.dart';
import 'package:pasteboard/pasteboard.dart';
import 'package:zxing2/qrcode.dart';
import '../../theme/theme.dart';
import '../../tools/tools.dart';
import 'invite_dialog.dart';
class BarcodeOverlay extends CustomPainter {
BarcodeOverlay({
required this.barcode,
required this.arguments,
required this.boxFit,
required this.capture,
});
final BarcodeCapture capture;
final Barcode barcode;
final MobileScannerArguments arguments;
final BoxFit boxFit;
@override
void paint(Canvas canvas, Size size) {
final adjustedSize = applyBoxFit(boxFit, arguments.size, size);
var verticalPadding = size.height - adjustedSize.destination.height;
var horizontalPadding = size.width - adjustedSize.destination.width;
if (verticalPadding > 0) {
verticalPadding = verticalPadding / 2;
} else {
verticalPadding = 0;
}
if (horizontalPadding > 0) {
horizontalPadding = horizontalPadding / 2;
} else {
horizontalPadding = 0;
}
final ratioWidth = (Platform.isIOS ? capture.width : arguments.size.width) /
adjustedSize.destination.width;
final ratioHeight =
(Platform.isIOS ? capture.height : arguments.size.height) /
adjustedSize.destination.height;
final adjustedOffset = <Offset>[];
for (final offset in barcode.corners) {
adjustedOffset.add(
Offset(
offset.dx / ratioWidth + horizontalPadding,
offset.dy / ratioHeight + verticalPadding,
),
);
}
final cutoutPath = Path()..addPolygon(adjustedOffset, true);
final backgroundPaint = Paint()
..color = Colors.red.withOpacity(0.3)
..style = PaintingStyle.fill
..blendMode = BlendMode.dstOut;
canvas.drawPath(cutoutPath, backgroundPaint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
class ScannerOverlay extends CustomPainter {
ScannerOverlay(this.scanWindow);
final Rect scanWindow;
@override
void paint(Canvas canvas, Size size) {
final backgroundPath = Path()..addRect(Rect.largest);
final cutoutPath = Path()..addRect(scanWindow);
final backgroundPaint = Paint()
..color = Colors.black.withOpacity(0.5)
..style = PaintingStyle.fill
..blendMode = BlendMode.dstOut;
final backgroundWithCutout = Path.combine(
PathOperation.difference,
backgroundPath,
cutoutPath,
);
canvas.drawPath(backgroundWithCutout, backgroundPaint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
class ScanInviteDialog extends StatefulWidget {
const ScanInviteDialog({super.key});
@override
ScanInviteDialogState createState() => ScanInviteDialogState();
static Future<void> show(BuildContext context) async {
await showStyledDialog<void>(
context: context,
title: translate('scan_invite_dialog.title'),
child: const ScanInviteDialog());
}
}
class ScanInviteDialogState extends State<ScanInviteDialog> {
bool scanned = false;
@override
void initState() {
super.initState();
}
void onValidationCancelled() {
setState(() {
scanned = false;
});
}
void onValidationSuccess() {}
void onValidationFailed() {
setState(() {
scanned = false;
});
}
bool inviteControlIsValid() => false; // _pasteTextController.text.isNotEmpty;
Future<Uint8List?> scanQRImage(BuildContext context) async {
final theme = Theme.of(context);
//final textTheme = theme.textTheme;
final scale = theme.extension<ScaleScheme>()!;
final windowSize = MediaQuery.of(context).size;
//final maxDialogWidth = min(windowSize.width - 64.0, 800.0 - 64.0);
//final maxDialogHeight = windowSize.height - 64.0;
final scanWindow = Rect.fromCenter(
center: MediaQuery.of(context).size.center(Offset.zero),
width: 200,
height: 200,
);
final cameraController = MobileScannerController();
try {
return showDialog(
context: context,
builder: (context) => Stack(
fit: StackFit.expand,
children: [
MobileScanner(
fit: BoxFit.contain,
scanWindow: scanWindow,
controller: cameraController,
errorBuilder: (context, error, child) =>
ScannerErrorWidget(error: error),
onDetect: (c) {
final barcode = c.barcodes.firstOrNull;
final barcodeBytes = barcode?.rawBytes;
if (barcodeBytes != null) {
cameraController.dispose();
Navigator.pop(context, barcodeBytes);
}
}),
CustomPaint(
painter: ScannerOverlay(scanWindow),
),
Align(
alignment: Alignment.bottomCenter,
child: Container(
alignment: Alignment.bottomCenter,
height: 100,
color: Colors.black.withOpacity(0.4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
IconButton(
color: Colors.white,
icon: ValueListenableBuilder(
valueListenable: cameraController.torchState,
builder: (context, state, child) {
switch (state) {
case TorchState.off:
return Icon(Icons.flash_off,
color:
scale.grayScale.subtleBackground);
case TorchState.on:
return Icon(Icons.flash_on,
color: scale.primaryScale.background);
}
},
),
iconSize: 32,
onPressed: cameraController.toggleTorch,
),
SizedBox(
width: windowSize.width - 120,
height: 50,
child: FittedBox(
child: Text(
translate('scan_invite_dialog.instructions'),
overflow: TextOverflow.fade,
style: Theme.of(context)
.textTheme
.labelLarge!
.copyWith(color: Colors.white),
),
),
),
IconButton(
color: Colors.white,
icon: ValueListenableBuilder(
valueListenable:
cameraController.cameraFacingState,
builder: (context, state, child) {
switch (state) {
case CameraFacing.front:
return const Icon(Icons.camera_front);
case CameraFacing.back:
return const Icon(Icons.camera_rear);
}
},
),
iconSize: 32,
onPressed: cameraController.switchCamera,
),
],
),
),
),
Align(
alignment: Alignment.topRight,
child: IconButton(
color: Colors.white,
icon: Icon(Icons.close,
color: scale.grayScale.background),
iconSize: 32,
onPressed: () => {
SchedulerBinding.instance
.addPostFrameCallback((_) {
cameraController.dispose();
Navigator.pop(context, null);
})
})),
],
));
} on MobileScannerException catch (e) {
if (e.errorCode == MobileScannerErrorCode.permissionDenied) {
showErrorToast(
context, translate('scan_invite_dialog.permission_error'));
} else {
showErrorToast(context, translate('scan_invite_dialog.error'));
}
} on Exception catch (_) {
showErrorToast(context, translate('scan_invite_dialog.error'));
}
return null;
}
Future<Uint8List?> pasteQRImage(BuildContext context) async {
final imageBytes = await Pasteboard.image;
if (imageBytes == null) {
if (context.mounted) {
showErrorToast(context, translate('scan_invite_dialog.not_an_image'));
}
return null;
}
final image = img.decodeImage(imageBytes);
if (image == null) {
if (context.mounted) {
showErrorToast(
context, translate('scan_invite_dialog.could_not_decode_image'));
}
return null;
}
try {
final source = RGBLuminanceSource(
image.width,
image.height,
image
.convert(numChannels: 4)
.getBytes(order: img.ChannelOrder.abgr)
.buffer
.asInt32List());
final bitmap = BinaryBitmap(HybridBinarizer(source));
final reader = QRCodeReader();
final result = reader.decode(bitmap);
final segs = result.resultMetadata[ResultMetadataType.byteSegments]!
as List<Int8List>;
return Uint8List.fromList(segs[0].toList());
} on Exception catch (_) {
if (context.mounted) {
showErrorToast(
context, translate('scan_invite_dialog.not_a_valid_qr_code'));
}
return null;
}
}
Widget buildInviteControl(
BuildContext context,
InviteDialogState dialogState,
Future<void> Function({required Uint8List inviteData})
validateInviteData) {
//final theme = Theme.of(context);
//final scale = theme.extension<ScaleScheme>()!;
//final textTheme = theme.textTheme;
//final height = MediaQuery.of(context).size.height;
if (isiOS || isAndroid) {
return Column(mainAxisSize: MainAxisSize.min, children: [
if (!scanned)
Text(
translate('scan_invite_dialog.scan_qr_here'),
).paddingLTRB(0, 0, 0, 8),
if (!scanned)
Container(
constraints: const BoxConstraints(maxHeight: 200),
child: ElevatedButton(
onPressed: dialogState.isValidating
? null
: () async {
final inviteData = await scanQRImage(context);
if (inviteData != null) {
setState(() {
scanned = true;
});
await validateInviteData(inviteData: inviteData);
}
},
child: Text(translate('scan_invite_dialog.scan'))),
).paddingLTRB(0, 0, 0, 8)
]);
}
return Column(mainAxisSize: MainAxisSize.min, children: [
if (!scanned)
Text(
translate('scan_invite_dialog.paste_qr_here'),
).paddingLTRB(0, 0, 0, 8),
if (!scanned)
Container(
constraints: const BoxConstraints(maxHeight: 200),
child: ElevatedButton(
onPressed: dialogState.isValidating
? null
: () async {
final inviteData = await pasteQRImage(context);
if (inviteData != null) {
await validateInviteData(inviteData: inviteData);
setState(() {
scanned = true;
});
}
},
child: Text(translate('scan_invite_dialog.paste'))),
).paddingLTRB(0, 0, 0, 8)
]);
}
@override
// ignore: prefer_expression_function_bodies
Widget build(BuildContext context) {
return InviteDialog(
onValidationCancelled: onValidationCancelled,
onValidationSuccess: onValidationSuccess,
onValidationFailed: onValidationFailed,
inviteControlIsValid: inviteControlIsValid,
buildInviteControl: buildInviteControl);
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<bool>('scanned', scanned));
}
}

View file

@ -0,0 +1,244 @@
import 'dart:async';
import 'dart:math';
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_translate/flutter_translate.dart';
import 'package:veilid_support/veilid_support.dart';
import '../../account_manager/account_manager.dart';
import '../../tools/tools.dart';
import 'contact_invitation_display.dart';
class SendInviteDialog extends StatefulWidget {
const SendInviteDialog({super.key});
@override
SendInviteDialogState createState() => SendInviteDialogState();
static Future<void> show(BuildContext context) async {
await showStyledDialog<void>(
context: context,
title: translate('send_invite_dialog.title'),
child: const SendInviteDialog());
}
}
class SendInviteDialogState extends State<SendInviteDialog> {
final _messageTextController = TextEditingController(
text: translate('send_invite_dialog.connect_with_me'));
EncryptionKeyType _encryptionKeyType = EncryptionKeyType.none;
String _encryptionKey = '';
Timestamp? _expiration;
@override
void initState() {
super.initState();
}
Future<void> _onNoneEncryptionSelected(bool selected) async {
setState(() {
if (selected) {
_encryptionKeyType = EncryptionKeyType.none;
}
});
}
Future<void> _onPinEncryptionSelected(bool selected) async {
final description = translate('send_invite_dialog.pin_description');
final pin = await showDialog<String>(
context: context,
builder: (context) =>
EnterPinDialog(reenter: false, description: description));
if (pin == null) {
return;
}
// ignore: use_build_context_synchronously
if (!context.mounted) {
return;
}
final matchpin = await showDialog<String>(
context: context,
builder: (context) => EnterPinDialog(
reenter: true,
description: description,
));
if (matchpin == null) {
return;
} else if (pin == matchpin) {
setState(() {
_encryptionKeyType = EncryptionKeyType.pin;
_encryptionKey = pin;
});
} else {
// ignore: use_build_context_synchronously
if (!context.mounted) {
return;
}
showErrorToast(
context, translate('send_invite_dialog.pin_does_not_match'));
setState(() {
_encryptionKeyType = EncryptionKeyType.none;
_encryptionKey = '';
});
}
}
Future<void> _onPasswordEncryptionSelected(bool selected) async {
final description = translate('send_invite_dialog.password_description');
final password = await showDialog<String>(
context: context,
builder: (context) => EnterPasswordDialog(description: description));
if (password == null) {
return;
}
// ignore: use_build_context_synchronously
if (!context.mounted) {
return;
}
final matchpass = await showDialog<String>(
context: context,
builder: (context) => EnterPasswordDialog(
matchPass: password,
description: description,
));
if (matchpass == null) {
return;
} else if (password == matchpass) {
setState(() {
_encryptionKeyType = EncryptionKeyType.password;
_encryptionKey = password;
});
} else {
// ignore: use_build_context_synchronously
if (!context.mounted) {
return;
}
showErrorToast(
context, translate('send_invite_dialog.password_does_not_match'));
setState(() {
_encryptionKeyType = EncryptionKeyType.none;
_encryptionKey = '';
});
}
}
Future<void> _onGenerateButtonPressed() async {
final navigator = Navigator.of(context);
// Start generation
final activeAccountInfo =
await AccountRepository.instance.fetchActiveAccountInfo();
if (activeAccountInfo == null) {
navigator.pop();
return;
}
final generator = createContactInvitation(
activeAccountInfo: activeAccountInfo,
encryptionKeyType: _encryptionKeyType,
encryptionKey: _encryptionKey,
message: _messageTextController.text,
expiration: _expiration);
// ignore: use_build_context_synchronously
if (!context.mounted) {
return;
}
await showDialog<void>(
context: context,
builder: (context) => ContactInvitationDisplayDialog(
name: activeAccountInfo.localAccount.name,
message: _messageTextController.text,
generator: generator,
));
// if (ret == null) {
// return;
// }
ref.invalidate(fetchContactInvitationRecordsProvider);
navigator.pop();
}
@override
// ignore: prefer_expression_function_bodies
Widget build(BuildContext context) {
final windowSize = MediaQuery.of(context).size;
final maxDialogWidth = min(windowSize.width - 64.0, 800.0 - 64.0);
final maxDialogHeight = windowSize.height - 64.0;
final theme = Theme.of(context);
//final scale = theme.extension<ScaleScheme>()!;
final textTheme = theme.textTheme;
return ConstrainedBox(
constraints:
BoxConstraints(maxHeight: maxDialogHeight, maxWidth: maxDialogWidth),
child: SingleChildScrollView(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Text(
translate('send_invite_dialog.message_to_contact'),
).paddingAll(8),
TextField(
controller: _messageTextController,
inputFormatters: [
LengthLimitingTextInputFormatter(128),
],
decoration: InputDecoration(
border: const OutlineInputBorder(),
hintText: translate('send_invite_dialog.enter_message_hint'),
labelText: translate('send_invite_dialog.message')),
).paddingAll(8),
const SizedBox(height: 10),
Text(translate('send_invite_dialog.protect_this_invitation'),
style: textTheme.labelLarge)
.paddingAll(8),
Wrap(spacing: 5, children: [
ChoiceChip(
label: Text(translate('send_invite_dialog.unlocked')),
selected: _encryptionKeyType == EncryptionKeyType.none,
onSelected: _onNoneEncryptionSelected,
),
ChoiceChip(
label: Text(translate('send_invite_dialog.pin')),
selected: _encryptionKeyType == EncryptionKeyType.pin,
onSelected: _onPinEncryptionSelected,
),
ChoiceChip(
label: Text(translate('send_invite_dialog.password')),
selected: _encryptionKeyType == EncryptionKeyType.password,
onSelected: _onPasswordEncryptionSelected,
)
]).paddingAll(8),
Container(
width: double.infinity,
height: 60,
padding: const EdgeInsets.all(8),
child: ElevatedButton(
onPressed: _onGenerateButtonPressed,
child: Text(
translate('send_invite_dialog.generate'),
),
),
),
Text(translate('send_invite_dialog.note')).paddingAll(8),
Text(
translate('send_invite_dialog.note_text'),
style: Theme.of(context).textTheme.bodySmall,
).paddingAll(8),
],
),
),
);
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<TextEditingController>(
'messageTextController', _messageTextController));
}
}

View file

@ -0,0 +1,8 @@
export 'contact_invitation_display.dart';
export 'contact_invitation_item_widget.dart';
export 'contact_invitation_list_widget.dart';
export 'invite_dialog.dart';
export 'new_contact_invitation_bottom_sheet.dart';
export 'paste_invite_dialog.dart';
export 'scan_invite_dialog.dart';
export 'send_invite_dialog.dart';