Merge branch 'dht-work' into 'main'

Complete refactor and upgrade to Veilid 0.3.0

See merge request veilid/veilidchat!24
This commit is contained in:
Christien Rioux 2024-04-01 13:22:59 +00:00
commit f03f373e82
284 changed files with 13870 additions and 8074 deletions

View File

@ -1,6 +1,13 @@
@echo off
dart run build_runner build --delete-conflicting-outputs
pushd packages\async_tools
call build.bat
popd
pushd packages\veilid_support
call build.bat
popd
pushd lib
protoc --dart_out=proto -I veilid_support\proto -I veilid_support\dht_support\proto -I proto veilidchat.proto
protoc --dart_out=proto -I veilid_support\proto -I veilid_support\dht_support\proto dht.proto

View File

@ -1,9 +1,16 @@
#!/bin/bash
set -e
pushd packages/async_tools > /dev/null
./build.sh
popd > /dev/null
pushd packages/veilid_support > /dev/null
./build.sh
popd > /dev/null
dart run build_runner build --delete-conflicting-outputs
pushd lib > /dev/null
protoc --dart_out=proto -I veilid_support/proto -I veilid_support/dht_support/proto -I proto veilidchat.proto
protoc --dart_out=proto -I veilid_support/proto -I veilid_support/dht_support/proto dht.proto
protoc --dart_out=proto -I veilid_support/proto veilid.proto
popd > /dev/null
protoc --dart_out=lib/proto -I packages/veilid_support/lib/proto -I packages/veilid_support/lib/dht_support/proto -I lib/proto veilidchat.proto
sed -i '' 's/dht.pb.dart/package:veilid_support\/proto\/dht.pb.dart/g' lib/proto/veilidchat.pb.dart
sed -i '' 's/veilid.pb.dart/package:veilid_support\/proto\/veilid.pb.dart/g' lib/proto/veilidchat.pb.dart

2
devtools_options.yaml Normal file
View File

@ -0,0 +1,2 @@
extensions:
- provider: true

View File

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

View File

@ -4,9 +4,6 @@ PODS:
- Flutter (1.0.0)
- flutter_native_splash (0.0.1):
- Flutter
- FMDB (2.7.5):
- FMDB/standard (= 2.7.5)
- FMDB/standard (2.7.5)
- GoogleDataTransport (9.2.5):
- GoogleUtilities/Environment (~> 7.7)
- nanopb (< 2.30910.0, >= 2.30908.0)
@ -55,7 +52,7 @@ PODS:
- GTMSessionFetcher/Core (< 3.0, >= 1.1)
- MLImage (= 1.0.0-beta4)
- MLKitCommon (~> 9.0)
- mobile_scanner (3.2.0):
- mobile_scanner (3.5.6):
- Flutter
- GoogleMLKit/BarcodeScanning (~> 4.0.0)
- nanopb (2.30909.0):
@ -78,7 +75,7 @@ PODS:
- Flutter
- sqflite (0.0.3):
- Flutter
- FMDB (>= 2.7.5)
- FlutterMacOS
- system_info_plus (0.0.1):
- Flutter
- url_launcher_ios (0.0.1):
@ -96,14 +93,13 @@ DEPENDENCIES:
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- smart_auth (from `.symlinks/plugins/smart_auth/ios`)
- sqflite (from `.symlinks/plugins/sqflite/ios`)
- sqflite (from `.symlinks/plugins/sqflite/darwin`)
- system_info_plus (from `.symlinks/plugins/system_info_plus/ios`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- veilid (from `.symlinks/plugins/veilid/ios`)
SPEC REPOS:
trunk:
- FMDB
- GoogleDataTransport
- GoogleMLKit
- GoogleToolboxForMac
@ -137,7 +133,7 @@ EXTERNAL SOURCES:
smart_auth:
:path: ".symlinks/plugins/smart_auth/ios"
sqflite:
:path: ".symlinks/plugins/sqflite/ios"
:path: ".symlinks/plugins/sqflite/darwin"
system_info_plus:
:path: ".symlinks/plugins/system_info_plus/ios"
url_launcher_ios:
@ -146,10 +142,9 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/veilid/ios"
SPEC CHECKSUMS:
camera_avfoundation: 3125e8cd1a4387f6f31c6c63abb8a55892a9eeeb
Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854
camera_avfoundation: 759172d1a77ae7be0de08fc104cfb79738b8a59e
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
GoogleDataTransport: 54dee9d48d14580407f8f5fbf2f496e92437a2f2
GoogleMLKit: 2bd0dc6253c4d4f227aad460f69215a504b2980e
GoogleToolboxForMac: 8bef7c7c5cf7291c687cf5354f39f9db6399ad34
@ -160,19 +155,19 @@ SPEC CHECKSUMS:
MLKitBarcodeScanning: 04e264482c5f3810cb89ebc134ef6b61e67db505
MLKitCommon: c1b791c3e667091918d91bda4bba69a91011e390
MLKitVision: 8baa5f46ee3352614169b85250574fde38c36f49
mobile_scanner: 47056db0c04027ea5f41a716385542da28574662
mobile_scanner: 38dcd8a49d7d485f632b7de65e4900010187aef2
nanopb: b552cce312b6c8484180ef47159bc0f65a1f0431
pasteboard: 982969ebaa7c78af3e6cc7761e8f5e77565d9ce0
path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943
path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c
PromisesObjC: c50d2056b5253dadbd6c2bea79b0674bd5a52fa4
share_plus: 599aa54e4ea31d4b4c0e9c911bcc26c55e791028
shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126
share_plus: c3fef564749587fc939ef86ffb283ceac0baf9f5
shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695
smart_auth: 4bedbc118723912d0e45a07e8ab34039c19e04f2
sqflite: 31f7eba61e3074736dff8807a9b41581e4f7f15a
sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec
system_info_plus: 5393c8da281d899950d751713575fbf91c7709aa
url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4
veilid: 51243c25047dbc1ebbfd87d713560260d802b845
url_launcher_ios: 6116280ddcfe98ab8820085d8d76ae7449447586
veilid: f5c2e662f91907b30cf95762619526ac3e4512fd
PODFILE CHECKSUM: 7f4cf2154d55730d953b184299e6feee7a274740
PODFILE CHECKSUM: 5d504085cd7c7a4d71ee600d7af087cb60ab75b2
COCOAPODS: 1.14.2
COCOAPODS: 1.15.2

View File

@ -155,7 +155,7 @@
97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject;
attributes = {
LastUpgradeCheck = 1430;
LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
97C146ED1CF9000F007C117D = {

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1430"
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"

View File

@ -0,0 +1,4 @@
export 'cubits/cubits.dart';
export 'models/models.dart';
export 'repository/repository.dart';
export 'views/views.dart';

View File

@ -0,0 +1,16 @@
import 'dart:async';
import 'package:veilid_support/veilid_support.dart';
import '../../proto/proto.dart' as proto;
class AccountRecordCubit extends DefaultDHTRecordCubit<proto.Account> {
AccountRecordCubit({
required super.open,
}) : super(decodeState: proto.Account.fromBuffer);
@override
Future<void> close() async {
await super.close();
}
}

View File

@ -0,0 +1,46 @@
import 'dart:async';
import 'package:bloc/bloc.dart';
import 'package:veilid_support/veilid_support.dart';
import '../../init.dart';
import '../repository/account_repository/account_repository.dart';
class ActiveLocalAccountCubit extends Cubit<TypedKey?> {
ActiveLocalAccountCubit(AccountRepository accountRepository)
: _accountRepository = accountRepository,
super(null) {
// Subscribe to streams
_initAccountRepositorySubscription();
// Initialize when we can
Future.delayed(Duration.zero, () async {
await eventualInitialized.future;
emit(_accountRepository.getActiveLocalAccount());
});
}
void _initAccountRepositorySubscription() {
_accountRepositorySubscription = _accountRepository.stream.listen((change) {
switch (change) {
case AccountRepositoryChange.activeLocalAccount:
emit(_accountRepository.getActiveLocalAccount());
break;
// Ignore these
case AccountRepositoryChange.localAccounts:
case AccountRepositoryChange.userLogins:
break;
}
});
}
@override
Future<void> close() async {
await super.close();
await _accountRepositorySubscription.cancel();
}
final AccountRepository _accountRepository;
late final StreamSubscription<AccountRepositoryChange>
_accountRepositorySubscription;
}

View File

@ -0,0 +1,4 @@
export 'account_record_cubit.dart';
export 'active_local_account_cubit.dart';
export 'local_accounts_cubit.dart';
export 'user_logins_cubit.dart';

View File

@ -0,0 +1,47 @@
import 'dart:async';
import 'package:bloc/bloc.dart';
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import '../../init.dart';
import '../models/models.dart';
import '../repository/account_repository/account_repository.dart';
class LocalAccountsCubit extends Cubit<IList<LocalAccount>> {
LocalAccountsCubit(AccountRepository accountRepository)
: _accountRepository = accountRepository,
super(IList<LocalAccount>()) {
// Subscribe to streams
_initAccountRepositorySubscription();
// Initialize when we can
Future.delayed(Duration.zero, () async {
await eventualInitialized.future;
emit(_accountRepository.getLocalAccounts());
});
}
void _initAccountRepositorySubscription() {
_accountRepositorySubscription = _accountRepository.stream.listen((change) {
switch (change) {
case AccountRepositoryChange.localAccounts:
emit(_accountRepository.getLocalAccounts());
break;
// Ignore these
case AccountRepositoryChange.userLogins:
case AccountRepositoryChange.activeLocalAccount:
break;
}
});
}
@override
Future<void> close() async {
await super.close();
await _accountRepositorySubscription.cancel();
}
final AccountRepository _accountRepository;
late final StreamSubscription<AccountRepositoryChange>
_accountRepositorySubscription;
}

View File

@ -0,0 +1,47 @@
import 'dart:async';
import 'package:bloc/bloc.dart';
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import '../../init.dart';
import '../models/models.dart';
import '../repository/account_repository/account_repository.dart';
class UserLoginsCubit extends Cubit<IList<UserLogin>> {
UserLoginsCubit(AccountRepository accountRepository)
: _accountRepository = accountRepository,
super(IList<UserLogin>()) {
// Subscribe to streams
_initAccountRepositorySubscription();
// Initialize when we can
Future.delayed(Duration.zero, () async {
await eventualInitialized.future;
emit(_accountRepository.getUserLogins());
});
}
void _initAccountRepositorySubscription() {
_accountRepositorySubscription = _accountRepository.stream.listen((change) {
switch (change) {
case AccountRepositoryChange.userLogins:
emit(_accountRepository.getUserLogins());
break;
// Ignore these
case AccountRepositoryChange.localAccounts:
case AccountRepositoryChange.activeLocalAccount:
break;
}
});
}
@override
Future<void> close() async {
await super.close();
await _accountRepositorySubscription.cancel();
}
final AccountRepository _accountRepository;
late final StreamSubscription<AccountRepositoryChange>
_accountRepositorySubscription;
}

View File

@ -0,0 +1,23 @@
import 'package:meta/meta.dart';
import 'active_account_info.dart';
enum AccountInfoStatus {
noAccount,
accountInvalid,
accountLocked,
accountReady,
}
@immutable
class AccountInfo {
const AccountInfo({
required this.status,
required this.active,
required this.activeAccountInfo,
});
final AccountInfoStatus status;
final bool active;
final ActiveAccountInfo? activeAccountInfo;
}

View File

@ -0,0 +1,45 @@
import 'dart:convert';
import 'package:meta/meta.dart';
import 'package:veilid_support/veilid_support.dart';
import 'local_account/local_account.dart';
import 'user_login/user_login.dart';
@immutable
class ActiveAccountInfo {
const ActiveAccountInfo({
required this.localAccount,
required this.userLogin,
//required this.accountRecord,
});
//
TypedKey get accountRecordKey =>
userLogin.accountRecordInfo.accountRecord.recordKey;
KeyPair get conversationWriter {
final identityKey = localAccount.identityMaster.identityPublicKey;
final identitySecret = userLogin.identitySecret;
return KeyPair(key: identityKey, secret: identitySecret.value);
}
Future<DHTRecordCrypto> makeConversationCrypto(
TypedKey remoteIdentityPublicKey) async {
final identitySecret = userLogin.identitySecret;
final cs = await Veilid.instance.getCryptoSystem(identitySecret.kind);
final sharedSecret = await cs.generateSharedSecret(
remoteIdentityPublicKey.value,
identitySecret.value,
utf8.encode('VeilidChat Conversation'));
final messagesCrypto = await DHTRecordCryptoPrivate.fromSecret(
identitySecret.kind, sharedSecret);
return messagesCrypto;
}
//
final LocalAccount localAccount;
final UserLogin userLogin;
//final DHTRecord accountRecord;
}

View File

@ -0,0 +1,79 @@
// Local account identitySecretKey is potentially encrypted with a key
// using the following mechanisms
// * None : no key, bytes are unencrypted
// * Pin : Code is a numeric pin (4-256 numeric digits) hashed with Argon2
// * Password: Code is a UTF-8 string that is hashed with Argon2
import 'dart:typed_data';
import 'package:change_case/change_case.dart';
import 'package:veilid_support/veilid_support.dart';
import '../../../proto/proto.dart' as proto;
enum EncryptionKeyType {
none,
pin,
password;
factory EncryptionKeyType.fromJson(dynamic j) =>
EncryptionKeyType.values.byName((j as String).toCamelCase());
factory EncryptionKeyType.fromProto(proto.EncryptionKeyType p) {
// ignore: exhaustive_cases
switch (p) {
case proto.EncryptionKeyType.ENCRYPTION_KEY_TYPE_NONE:
return EncryptionKeyType.none;
case proto.EncryptionKeyType.ENCRYPTION_KEY_TYPE_PIN:
return EncryptionKeyType.pin;
case proto.EncryptionKeyType.ENCRYPTION_KEY_TYPE_PASSWORD:
return EncryptionKeyType.password;
}
throw StateError('unknown EncryptionKeyType enum value');
}
String toJson() => name.toPascalCase();
proto.EncryptionKeyType toProto() => switch (this) {
EncryptionKeyType.none =>
proto.EncryptionKeyType.ENCRYPTION_KEY_TYPE_NONE,
EncryptionKeyType.pin =>
proto.EncryptionKeyType.ENCRYPTION_KEY_TYPE_PIN,
EncryptionKeyType.password =>
proto.EncryptionKeyType.ENCRYPTION_KEY_TYPE_PASSWORD,
};
Future<Uint8List> encryptSecretToBytes(
{required SecretKey secret,
required CryptoKind cryptoKind,
String encryptionKey = ''}) async {
late final Uint8List secretBytes;
switch (this) {
case EncryptionKeyType.none:
secretBytes = secret.decode();
case EncryptionKeyType.pin:
case EncryptionKeyType.password:
final cs = await Veilid.instance.getCryptoSystem(cryptoKind);
secretBytes =
await cs.encryptAeadWithPassword(secret.decode(), encryptionKey);
}
return secretBytes;
}
Future<SecretKey> decryptSecretFromBytes(
{required Uint8List secretBytes,
required CryptoKind cryptoKind,
String encryptionKey = ''}) async {
late final SecretKey secret;
switch (this) {
case EncryptionKeyType.none:
secret = SecretKey.fromBytes(secretBytes);
case EncryptionKeyType.pin:
case EncryptionKeyType.password:
final cs = await Veilid.instance.getCryptoSystem(cryptoKind);
secret = SecretKey.fromBytes(
await cs.decryptAeadWithPassword(secretBytes, encryptionKey));
}
return secret;
}
}

View File

@ -0,0 +1,39 @@
import 'dart:typed_data';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:veilid_support/veilid_support.dart';
import '../../models/encryption_key_type.dart';
part 'local_account.g.dart';
part 'local_account.freezed.dart';
// Local Accounts are stored in a table locally and not backed by a DHT key
// and represents the accounts that have been added/imported
// on the current device.
// Stores a copy of the IdentityMaster associated with the account
// and the identitySecretKey optionally encrypted by an unlock code
// This is the root of the account information tree for VeilidChat
//
@freezed
class LocalAccount with _$LocalAccount {
const factory LocalAccount({
// The master key record for the account, containing the identityPublicKey
required IdentityMaster identityMaster,
// The encrypted identity secret that goes with
// the identityPublicKey with appended salt
@Uint8ListJsonConverter() required Uint8List identitySecretBytes,
// The kind of encryption input used on the account
required EncryptionKeyType encryptionKeyType,
// If account is not hidden, password can be retrieved via
required bool biometricsEnabled,
// Keep account hidden unless account password is entered
// (tries all hidden accounts with auth method (no biometrics))
required bool hiddenAccount,
// Display name for account until it is unlocked
required String name,
}) = _LocalAccount;
factory LocalAccount.fromJson(dynamic json) =>
_$LocalAccountFromJson(json as Map<String, dynamic>);
}

View File

@ -12,7 +12,7 @@ part of 'local_account.dart';
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#custom-getters-and-methods');
'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');
LocalAccount _$LocalAccountFromJson(Map<String, dynamic> json) {
return _LocalAccount.fromJson(json);
@ -225,7 +225,7 @@ class _$LocalAccountImpl implements _LocalAccount {
}
@override
bool operator ==(dynamic other) {
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$LocalAccountImpl &&

View File

@ -0,0 +1,6 @@
export 'account_info.dart';
export 'active_account_info.dart';
export 'encryption_key_type.dart';
export 'local_account/local_account.dart';
export 'new_profile_spec.dart';
export 'user_login/user_login.dart';

View File

@ -0,0 +1,5 @@
class NewProfileSpec {
NewProfileSpec({required this.name, required this.pronouns});
String name;
String pronouns;
}

View File

@ -1,7 +1,6 @@
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import '../veilid_support/veilid_support.dart';
import 'package:veilid_support/veilid_support.dart';
part 'user_login.freezed.dart';
part 'user_login.g.dart';
@ -26,21 +25,3 @@ class UserLogin with _$UserLogin {
factory UserLogin.fromJson(dynamic json) =>
_$UserLoginFromJson(json as Map<String, dynamic>);
}
// Represents a set of user logins
// and the currently selected account
@freezed
class ActiveLogins with _$ActiveLogins {
const factory ActiveLogins({
// The list of current logged in accounts
required IList<UserLogin> userLogins,
// The current selected account indexed by master record key
TypedKey? activeUserLogin,
}) = _ActiveLogins;
factory ActiveLogins.empty() =>
const ActiveLogins(userLogins: IListConst([]));
factory ActiveLogins.fromJson(dynamic json) =>
_$ActiveLoginsFromJson(json as Map<String, dynamic>);
}

View File

@ -12,7 +12,7 @@ part of 'user_login.dart';
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#custom-getters-and-methods');
'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');
UserLogin _$UserLoginFromJson(Map<String, dynamic> json) {
return _UserLogin.fromJson(json);
@ -182,7 +182,7 @@ class _$UserLoginImpl implements _UserLogin {
}
@override
bool operator ==(dynamic other) {
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$UserLoginImpl &&
@ -238,169 +238,3 @@ abstract class _UserLogin implements UserLogin {
_$$UserLoginImplCopyWith<_$UserLoginImpl> get copyWith =>
throw _privateConstructorUsedError;
}
ActiveLogins _$ActiveLoginsFromJson(Map<String, dynamic> json) {
return _ActiveLogins.fromJson(json);
}
/// @nodoc
mixin _$ActiveLogins {
// The list of current logged in accounts
IList<UserLogin> get userLogins =>
throw _privateConstructorUsedError; // The current selected account indexed by master record key
Typed<FixedEncodedString43>? get activeUserLogin =>
throw _privateConstructorUsedError;
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
@JsonKey(ignore: true)
$ActiveLoginsCopyWith<ActiveLogins> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $ActiveLoginsCopyWith<$Res> {
factory $ActiveLoginsCopyWith(
ActiveLogins value, $Res Function(ActiveLogins) then) =
_$ActiveLoginsCopyWithImpl<$Res, ActiveLogins>;
@useResult
$Res call(
{IList<UserLogin> userLogins,
Typed<FixedEncodedString43>? activeUserLogin});
}
/// @nodoc
class _$ActiveLoginsCopyWithImpl<$Res, $Val extends ActiveLogins>
implements $ActiveLoginsCopyWith<$Res> {
_$ActiveLoginsCopyWithImpl(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? userLogins = null,
Object? activeUserLogin = freezed,
}) {
return _then(_value.copyWith(
userLogins: null == userLogins
? _value.userLogins
: userLogins // ignore: cast_nullable_to_non_nullable
as IList<UserLogin>,
activeUserLogin: freezed == activeUserLogin
? _value.activeUserLogin
: activeUserLogin // ignore: cast_nullable_to_non_nullable
as Typed<FixedEncodedString43>?,
) as $Val);
}
}
/// @nodoc
abstract class _$$ActiveLoginsImplCopyWith<$Res>
implements $ActiveLoginsCopyWith<$Res> {
factory _$$ActiveLoginsImplCopyWith(
_$ActiveLoginsImpl value, $Res Function(_$ActiveLoginsImpl) then) =
__$$ActiveLoginsImplCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{IList<UserLogin> userLogins,
Typed<FixedEncodedString43>? activeUserLogin});
}
/// @nodoc
class __$$ActiveLoginsImplCopyWithImpl<$Res>
extends _$ActiveLoginsCopyWithImpl<$Res, _$ActiveLoginsImpl>
implements _$$ActiveLoginsImplCopyWith<$Res> {
__$$ActiveLoginsImplCopyWithImpl(
_$ActiveLoginsImpl _value, $Res Function(_$ActiveLoginsImpl) _then)
: super(_value, _then);
@pragma('vm:prefer-inline')
@override
$Res call({
Object? userLogins = null,
Object? activeUserLogin = freezed,
}) {
return _then(_$ActiveLoginsImpl(
userLogins: null == userLogins
? _value.userLogins
: userLogins // ignore: cast_nullable_to_non_nullable
as IList<UserLogin>,
activeUserLogin: freezed == activeUserLogin
? _value.activeUserLogin
: activeUserLogin // ignore: cast_nullable_to_non_nullable
as Typed<FixedEncodedString43>?,
));
}
}
/// @nodoc
@JsonSerializable()
class _$ActiveLoginsImpl implements _ActiveLogins {
const _$ActiveLoginsImpl({required this.userLogins, this.activeUserLogin});
factory _$ActiveLoginsImpl.fromJson(Map<String, dynamic> json) =>
_$$ActiveLoginsImplFromJson(json);
// The list of current logged in accounts
@override
final IList<UserLogin> userLogins;
// The current selected account indexed by master record key
@override
final Typed<FixedEncodedString43>? activeUserLogin;
@override
String toString() {
return 'ActiveLogins(userLogins: $userLogins, activeUserLogin: $activeUserLogin)';
}
@override
bool operator ==(dynamic other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$ActiveLoginsImpl &&
const DeepCollectionEquality()
.equals(other.userLogins, userLogins) &&
(identical(other.activeUserLogin, activeUserLogin) ||
other.activeUserLogin == activeUserLogin));
}
@JsonKey(ignore: true)
@override
int get hashCode => Object.hash(runtimeType,
const DeepCollectionEquality().hash(userLogins), activeUserLogin);
@JsonKey(ignore: true)
@override
@pragma('vm:prefer-inline')
_$$ActiveLoginsImplCopyWith<_$ActiveLoginsImpl> get copyWith =>
__$$ActiveLoginsImplCopyWithImpl<_$ActiveLoginsImpl>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$ActiveLoginsImplToJson(
this,
);
}
}
abstract class _ActiveLogins implements ActiveLogins {
const factory _ActiveLogins(
{required final IList<UserLogin> userLogins,
final Typed<FixedEncodedString43>? activeUserLogin}) = _$ActiveLoginsImpl;
factory _ActiveLogins.fromJson(Map<String, dynamic> json) =
_$ActiveLoginsImpl.fromJson;
@override // The list of current logged in accounts
IList<UserLogin> get userLogins;
@override // The current selected account indexed by master record key
Typed<FixedEncodedString43>? get activeUserLogin;
@override
@JsonKey(ignore: true)
_$$ActiveLoginsImplCopyWith<_$ActiveLoginsImpl> get copyWith =>
throw _privateConstructorUsedError;
}

View File

@ -24,20 +24,3 @@ Map<String, dynamic> _$$UserLoginImplToJson(_$UserLoginImpl instance) =>
'account_record_info': instance.accountRecordInfo.toJson(),
'last_active': instance.lastActive.toJson(),
};
_$ActiveLoginsImpl _$$ActiveLoginsImplFromJson(Map<String, dynamic> json) =>
_$ActiveLoginsImpl(
userLogins: IList<UserLogin>.fromJson(
json['user_logins'], (value) => UserLogin.fromJson(value)),
activeUserLogin: json['active_user_login'] == null
? null
: Typed<FixedEncodedString43>.fromJson(json['active_user_login']),
);
Map<String, dynamic> _$$ActiveLoginsImplToJson(_$ActiveLoginsImpl instance) =>
<String, dynamic>{
'user_logins': instance.userLogins.toJson(
(value) => value.toJson(),
),
'active_user_login': instance.activeUserLogin?.toJson(),
};

View File

@ -0,0 +1,398 @@
import 'dart:async';
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:veilid_support/veilid_support.dart';
import '../../../../proto/proto.dart' as proto;
import '../../../tools/tools.dart';
import '../../models/models.dart';
const String veilidChatAccountKey = 'com.veilid.veilidchat';
enum AccountRepositoryChange { localAccounts, userLogins, activeLocalAccount }
class AccountRepository {
AccountRepository._()
: _localAccounts = _initLocalAccounts(),
_userLogins = _initUserLogins(),
_activeLocalAccount = _initActiveAccount(),
_streamController =
StreamController<AccountRepositoryChange>.broadcast();
static TableDBValue<IList<LocalAccount>> _initLocalAccounts() => TableDBValue(
tableName: 'local_account_manager',
tableKeyName: 'local_accounts',
valueFromJson: (obj) => obj != null
? IList<LocalAccount>.fromJson(
obj, genericFromJson(LocalAccount.fromJson))
: IList<LocalAccount>(),
valueToJson: (val) => val.toJson((la) => la.toJson()));
static TableDBValue<IList<UserLogin>> _initUserLogins() => TableDBValue(
tableName: 'local_account_manager',
tableKeyName: 'user_logins',
valueFromJson: (obj) => obj != null
? IList<UserLogin>.fromJson(obj, genericFromJson(UserLogin.fromJson))
: IList<UserLogin>(),
valueToJson: (val) => val.toJson((la) => la.toJson()));
static TableDBValue<TypedKey?> _initActiveAccount() => TableDBValue(
tableName: 'local_account_manager',
tableKeyName: 'active_local_account',
valueFromJson: (obj) => obj == null ? null : TypedKey.fromJson(obj),
valueToJson: (val) => val?.toJson());
//////////////////////////////////////////////////////////////
/// Fields
final TableDBValue<IList<LocalAccount>> _localAccounts;
final TableDBValue<IList<UserLogin>> _userLogins;
final TableDBValue<TypedKey?> _activeLocalAccount;
final StreamController<AccountRepositoryChange> _streamController;
//////////////////////////////////////////////////////////////
/// Singleton initialization
static AccountRepository instance = AccountRepository._();
Future<void> init() async {
await _localAccounts.get();
await _userLogins.get();
await _activeLocalAccount.get();
}
Future<void> close() async {
// ???
}
//////////////////////////////////////////////////////////////
/// Streams
Stream<AccountRepositoryChange> get stream => _streamController.stream;
//////////////////////////////////////////////////////////////
/// Selectors
IList<LocalAccount> getLocalAccounts() => _localAccounts.requireValue;
TypedKey? getActiveLocalAccount() => _activeLocalAccount.requireValue;
IList<UserLogin> getUserLogins() => _userLogins.requireValue;
UserLogin? getActiveUserLogin() {
final activeLocalAccount = _activeLocalAccount.requireValue;
return activeLocalAccount == null
? null
: fetchUserLogin(activeLocalAccount);
}
LocalAccount? fetchLocalAccount(TypedKey accountMasterRecordKey) {
final localAccounts = _localAccounts.requireValue;
final idx = localAccounts.indexWhere(
(e) => e.identityMaster.masterRecordKey == accountMasterRecordKey);
if (idx == -1) {
return null;
}
return localAccounts[idx];
}
UserLogin? fetchUserLogin(TypedKey accountMasterRecordKey) {
final userLogins = _userLogins.requireValue;
final idx = userLogins
.indexWhere((e) => e.accountMasterRecordKey == accountMasterRecordKey);
if (idx == -1) {
return null;
}
return userLogins[idx];
}
AccountInfo getAccountInfo(TypedKey? accountMasterRecordKey) {
// Get active account if we have one
final activeLocalAccount = getActiveLocalAccount();
if (accountMasterRecordKey == null) {
if (activeLocalAccount == null) {
// No user logged in
return const AccountInfo(
status: AccountInfoStatus.noAccount,
active: false,
activeAccountInfo: null);
}
accountMasterRecordKey = activeLocalAccount;
}
final active = accountMasterRecordKey == activeLocalAccount;
// Get which local account we want to fetch the profile for
final localAccount = fetchLocalAccount(accountMasterRecordKey);
if (localAccount == null) {
// account does not exist
return AccountInfo(
status: AccountInfoStatus.noAccount,
active: active,
activeAccountInfo: null);
}
// See if we've logged into this account or if it is locked
final userLogin = fetchUserLogin(accountMasterRecordKey);
if (userLogin == null) {
// Account was locked
return AccountInfo(
status: AccountInfoStatus.accountLocked,
active: active,
activeAccountInfo: null);
}
// Got account, decrypted and decoded
return AccountInfo(
status: AccountInfoStatus.accountReady,
active: active,
activeAccountInfo:
ActiveAccountInfo(localAccount: localAccount, userLogin: userLogin),
);
}
//////////////////////////////////////////////////////////////
/// Mutators
/// Reorder accounts
Future<void> reorderAccount(int oldIndex, int newIndex) async {
final localAccounts = await _localAccounts.get();
final removedItem = Output<LocalAccount>();
final updated = localAccounts
.removeAt(oldIndex, removedItem)
.insert(newIndex, removedItem.value!);
await _localAccounts.set(updated);
_streamController.add(AccountRepositoryChange.localAccounts);
}
/// Creates a new master identity, an account associated with the master
/// identity, stores the account in the identity key and then logs into
/// that account with no password set at this time
Future<void> createWithNewMasterIdentity(
NewProfileSpec newProfileSpec) async {
log.debug('Creating master identity');
final imws = await IdentityMasterWithSecrets.create();
try {
final localAccount = await _newLocalAccount(
identityMaster: imws.identityMaster,
identitySecret: imws.identitySecret,
newProfileSpec: newProfileSpec);
// Log in the new account by default with no pin
final ok = await login(localAccount.identityMaster.masterRecordKey,
EncryptionKeyType.none, '');
assert(ok, 'login with none should never fail');
} on Exception catch (_) {
await imws.delete();
rethrow;
}
}
/// Creates a new Account associated with master identity
/// Adds a logged-out LocalAccount to track its existence on this device
Future<LocalAccount> _newLocalAccount(
{required IdentityMaster identityMaster,
required SecretKey identitySecret,
required NewProfileSpec newProfileSpec,
EncryptionKeyType encryptionKeyType = EncryptionKeyType.none,
String encryptionKey = ''}) async {
log.debug('Creating new local account');
final localAccounts = await _localAccounts.get();
// Add account with profile to DHT
await identityMaster.addAccountToIdentity(
identitySecret: identitySecret,
accountKey: veilidChatAccountKey,
createAccountCallback: (parent) async {
// Make empty contact list
log.debug('Creating contacts list');
final contactList = await (await DHTShortArray.create(parent: parent))
.scope((r) async => r.recordPointer);
// Make empty contact invitation record list
log.debug('Creating contact invitation records list');
final contactInvitationRecords =
await (await DHTShortArray.create(parent: parent))
.scope((r) async => r.recordPointer);
// Make empty chat record list
log.debug('Creating chat records list');
final chatRecords = await (await DHTShortArray.create(parent: parent))
.scope((r) async => r.recordPointer);
// Make account object
final account = proto.Account()
..profile = (proto.Profile()
..name = newProfileSpec.name
..pronouns = newProfileSpec.pronouns)
..contactList = contactList.toProto()
..contactInvitationRecords = contactInvitationRecords.toProto()
..chatList = chatRecords.toProto();
return account;
});
// Encrypt identitySecret with key
final identitySecretBytes = await encryptionKeyType.encryptSecretToBytes(
secret: identitySecret,
cryptoKind: identityMaster.identityRecordKey.kind,
encryptionKey: encryptionKey,
);
// Create local account object
// Does not contain the account key or its secret
// as that is not to be persisted, and only pulled from the identity key
// and optionally decrypted with the unlock password
final localAccount = LocalAccount(
identityMaster: identityMaster,
identitySecretBytes: identitySecretBytes,
encryptionKeyType: encryptionKeyType,
biometricsEnabled: false,
hiddenAccount: false,
name: newProfileSpec.name,
);
// Add local account object to internal store
final newLocalAccounts = localAccounts.add(localAccount);
await _localAccounts.set(newLocalAccounts);
_streamController.add(AccountRepositoryChange.localAccounts);
// Return local account object
return localAccount;
}
/// Remove an account and wipe the messages for this account from this device
Future<bool> deleteLocalAccount(TypedKey accountMasterRecordKey) async {
await logout(accountMasterRecordKey);
final localAccounts = await _localAccounts.get();
final newLocalAccounts = localAccounts.removeWhere(
(la) => la.identityMaster.masterRecordKey == accountMasterRecordKey);
await _localAccounts.set(newLocalAccounts);
_streamController.add(AccountRepositoryChange.localAccounts);
// TO DO: wipe messages
return true;
}
/// Import an account from another VeilidChat instance
/// Recover an account with the master identity secret
/// Delete an account from all devices
Future<void> switchToAccount(TypedKey? accountMasterRecordKey) async {
final activeLocalAccount = await _activeLocalAccount.get();
if (activeLocalAccount == accountMasterRecordKey) {
// Nothing to do
return;
}
if (accountMasterRecordKey != null) {
// Assert the specified record key can be found, will throw if not
final _ = _userLogins.requireValue.firstWhere(
(ul) => ul.accountMasterRecordKey == accountMasterRecordKey);
}
await _activeLocalAccount.set(accountMasterRecordKey);
_streamController.add(AccountRepositoryChange.activeLocalAccount);
}
Future<bool> _decryptedLogin(
IdentityMaster identityMaster, SecretKey identitySecret) async {
// Verify identity secret works and return the valid cryptosystem
final cs = await identityMaster.validateIdentitySecret(identitySecret);
// Read the identity key to get the account keys
final accountRecordInfoList = await identityMaster.readAccountsFromIdentity(
identitySecret: identitySecret, accountKey: veilidChatAccountKey);
if (accountRecordInfoList.length > 1) {
throw IdentityException.limitExceeded;
} else if (accountRecordInfoList.isEmpty) {
throw IdentityException.noAccount;
}
final accountRecordInfo = accountRecordInfoList.single;
// Add to user logins and select it
final userLogins = await _userLogins.get();
final now = Veilid.instance.now();
final newUserLogins = userLogins.replaceFirstWhere(
(ul) => ul.accountMasterRecordKey == identityMaster.masterRecordKey,
(ul) => ul != null
? ul.copyWith(lastActive: now)
: UserLogin(
accountMasterRecordKey: identityMaster.masterRecordKey,
identitySecret:
TypedSecret(kind: cs.kind(), value: identitySecret),
accountRecordInfo: accountRecordInfo,
lastActive: now),
addIfNotFound: true);
await _userLogins.set(newUserLogins);
await _activeLocalAccount.set(identityMaster.masterRecordKey);
_streamController
..add(AccountRepositoryChange.userLogins)
..add(AccountRepositoryChange.activeLocalAccount);
return true;
}
Future<bool> login(TypedKey accountMasterRecordKey,
EncryptionKeyType encryptionKeyType, String encryptionKey) async {
final localAccounts = await _localAccounts.get();
// Get account, throws if not found
final localAccount = localAccounts.firstWhere(
(la) => la.identityMaster.masterRecordKey == accountMasterRecordKey);
// Log in with this local account
// Derive key from password
if (localAccount.encryptionKeyType != encryptionKeyType) {
throw Exception('Wrong authentication type');
}
final identitySecret =
await localAccount.encryptionKeyType.decryptSecretFromBytes(
secretBytes: localAccount.identitySecretBytes,
cryptoKind: localAccount.identityMaster.identityRecordKey.kind,
encryptionKey: encryptionKey,
);
// Validate this secret with the identity public key and log in
return _decryptedLogin(localAccount.identityMaster, identitySecret);
}
Future<void> logout(TypedKey? accountMasterRecordKey) async {
// Resolve which user to log out
final activeLocalAccount = await _activeLocalAccount.get();
final logoutUser = accountMasterRecordKey ?? activeLocalAccount;
if (logoutUser == null) {
log.error('missing user in logout: $accountMasterRecordKey');
return;
}
final logoutUserLogin = fetchUserLogin(logoutUser);
if (logoutUserLogin == null) {
// Already logged out
return;
}
// Remove user from active logins list
final newUserLogins = (await _userLogins.get())
.removeWhere((ul) => ul.accountMasterRecordKey == logoutUser);
await _userLogins.set(newUserLogins);
_streamController.add(AccountRepositoryChange.userLogins);
}
Future<DHTRecord> openAccountRecord(UserLogin userLogin) async {
final localAccount = fetchLocalAccount(userLogin.accountMasterRecordKey)!;
// Record not yet open, do it
final pool = DHTRecordPool.instance;
final record = await pool.openOwned(
userLogin.accountRecordInfo.accountRecord,
parent: localAccount.identityMaster.identityRecordKey);
return record;
}
}

View File

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

View File

@ -2,28 +2,23 @@ import 'package:awesome_extensions/awesome_extensions.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_translate/flutter_translate.dart';
import 'package:form_builder_validators/form_builder_validators.dart';
import 'package:go_router/go_router.dart';
import '../components/default_app_bar.dart';
import '../components/signal_strength_meter.dart';
import '../entities/entities.dart';
import '../providers/local_accounts.dart';
import '../providers/logins.dart';
import '../providers/window_control.dart';
import '../tools/tools.dart';
import '../veilid_support/veilid_support.dart';
import '../../../layout/default_app_bar.dart';
import '../../../tools/tools.dart';
import '../../../veilid_processor/veilid_processor.dart';
import '../../account_manager.dart';
class NewAccountPage extends ConsumerStatefulWidget {
class NewAccountPage extends StatefulWidget {
const NewAccountPage({super.key});
@override
NewAccountPageState createState() => NewAccountPageState();
}
class NewAccountPageState extends ConsumerState<NewAccountPage> {
class NewAccountPageState extends State<NewAccountPage> {
final _formKey = GlobalKey<FormBuilderState>();
late bool isInAsyncCall = false;
static const String formFieldName = 'name';
@ -34,42 +29,11 @@ class NewAccountPageState extends ConsumerState<NewAccountPage> {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) async {
setState(() {});
await ref.read(windowControlProvider.notifier).changeWindowSetup(
await changeWindowSetup(
TitleBarStyle.normal, OrientationCapability.portraitOnly);
});
}
/// Creates a new master identity, an account associated with the master
/// identity, stores the account in the identity key and then logs into
/// that account with no password set at this time
Future<void> createAccount() async {
final localAccounts = ref.read(localAccountsProvider.notifier);
final logins = ref.read(loginsProvider.notifier);
final name = _formKey.currentState!.fields[formFieldName]!.value as String;
final pronouns =
_formKey.currentState!.fields[formFieldPronouns]!.value as String? ??
'';
final imws = await IdentityMasterWithSecrets.create();
try {
final localAccount = await localAccounts.newLocalAccount(
identityMaster: imws.identityMaster,
identitySecret: imws.identitySecret,
name: name,
pronouns: pronouns);
// Log in the new account by default with no pin
final ok = await logins.login(localAccount.identityMaster.masterRecordKey,
EncryptionKeyType.none, '');
assert(ok, 'login with none should never fail');
} on Exception catch (_) {
await imws.delete();
rethrow;
}
}
Widget _newAccountForm(BuildContext context,
{required Future<void> Function(GlobalKey<FormBuilderState>)
onSubmit}) =>
@ -90,12 +54,14 @@ class NewAccountPageState extends ConsumerState<NewAccountPage> {
validator: FormBuilderValidators.compose([
FormBuilderValidators.required(),
]),
textInputAction: TextInputAction.next,
),
FormBuilderTextField(
name: formFieldPronouns,
maxLength: 64,
decoration: InputDecoration(
labelText: translate('account.form_pronouns')),
textInputAction: TextInputAction.next,
),
Row(children: [
const Spacer(),
@ -129,13 +95,7 @@ class NewAccountPageState extends ConsumerState<NewAccountPage> {
@override
Widget build(BuildContext context) {
ref.watch(windowControlProvider);
final localAccounts = ref.watch(localAccountsProvider);
final logins = ref.watch(loginsProvider);
final displayModalHUD =
isInAsyncCall || !localAccounts.hasValue || !logins.hasValue;
final displayModalHUD = isInAsyncCall;
return Scaffold(
// resizeToAvoidBottomInset: false,
@ -147,7 +107,7 @@ class NewAccountPageState extends ConsumerState<NewAccountPage> {
icon: const Icon(Icons.settings),
tooltip: translate('app_bar.settings_tooltip'),
onPressed: () async {
context.go('/new_account/settings');
await GoRouterHelper(context).push('/settings');
})
]),
body: _newAccountForm(
@ -155,7 +115,16 @@ class NewAccountPageState extends ConsumerState<NewAccountPage> {
onSubmit: (formKey) async {
FocusScope.of(context).unfocus();
try {
await createAccount();
final name =
_formKey.currentState!.fields[formFieldName]!.value as String;
final pronouns = _formKey.currentState!.fields[formFieldPronouns]!
.value as String? ??
'';
final newProfileSpec =
NewProfileSpec(name: name, pronouns: pronouns);
await AccountRepository.instance
.createWithNewMasterIdentity(newProfileSpec);
} on Exception catch (e) {
if (context.mounted) {
await showErrorModal(context, translate('new_account_page.error'),

View File

@ -0,0 +1,43 @@
import 'package:awesome_extensions/awesome_extensions.dart';
import 'package:flutter/material.dart';
import '../../proto/proto.dart' as proto;
import '../../theme/theme.dart';
class ProfileWidget extends StatelessWidget {
const ProfileWidget({
required proto.Profile profile,
super.key,
}) : _profile = profile;
//
final proto.Profile _profile;
//
@override
// ignore: prefer_expression_function_bodies
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scale = theme.extension<ScaleScheme>()!;
final textTheme = theme.textTheme;
return DecoratedBox(
decoration: ShapeDecoration(
color: scale.primaryScale.border,
shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))),
child: Column(children: [
Text(
_profile.name,
style: textTheme.headlineSmall,
textAlign: TextAlign.left,
).paddingAll(4),
if (_profile.pronouns.isNotEmpty)
Text(_profile.pronouns, style: textTheme.bodyMedium)
.paddingLTRB(4, 0, 4, 4),
]),
);
}
}

View File

@ -0,0 +1,2 @@
export 'new_account_page/new_account_page.dart';
export 'profile_widget.dart';

View File

@ -1,35 +1,65 @@
import 'package:animated_theme_switcher/animated_theme_switcher.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_translate/flutter_translate.dart';
import 'package:form_builder_validators/form_builder_validators.dart';
import 'account_manager/account_manager.dart';
import 'router/router.dart';
import 'settings/settings.dart';
import 'tick.dart';
import 'veilid_processor/veilid_processor.dart';
class VeilidChatApp extends ConsumerWidget {
class VeilidChatApp extends StatelessWidget {
const VeilidChatApp({
required this.theme,
required this.initialThemeData,
super.key,
});
final ThemeData theme;
static const String name = 'VeilidChat';
final ThemeData initialThemeData;
@override
Widget build(BuildContext context, WidgetRef ref) {
final router = ref.watch(routerProvider);
Widget build(BuildContext context) {
final localizationDelegate = LocalizedApp.of(context).delegate;
return ThemeProvider(
initTheme: theme,
initTheme: initialThemeData,
builder: (_, theme) => LocalizationProvider(
state: LocalizationProvider.of(context).state,
child: MultiBlocProvider(
providers: [
BlocProvider<ConnectionStateCubit>(
create: (context) =>
ConnectionStateCubit(ProcessorRepository.instance)),
BlocProvider<RouterCubit>(
create: (context) =>
RouterCubit(AccountRepository.instance),
),
BlocProvider<LocalAccountsCubit>(
create: (context) =>
LocalAccountsCubit(AccountRepository.instance),
),
BlocProvider<UserLoginsCubit>(
create: (context) =>
UserLoginsCubit(AccountRepository.instance),
),
BlocProvider<ActiveLocalAccountCubit>(
create: (context) =>
ActiveLocalAccountCubit(AccountRepository.instance),
),
BlocProvider<PreferencesCubit>(
create: (context) =>
PreferencesCubit(PreferencesRepository.instance),
)
],
child: BackgroundTicker(
builder: (context) => MaterialApp.router(
debugShowCheckedModeBanner: false,
routerConfig: router,
routerConfig: context.watch<RouterCubit>().router(),
title: translate('app.title'),
theme: theme,
localizationsDelegates: [
@ -40,6 +70,7 @@ class VeilidChatApp extends ConsumerWidget {
],
supportedLocales: localizationDelegate.supportedLocales,
locale: localizationDelegate.currentLocale,
),
)),
));
}
@ -47,6 +78,7 @@ class VeilidChatApp extends ConsumerWidget {
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<ThemeData>('theme', theme));
properties
.add(DiagnosticsProperty<ThemeData>('themeData', initialThemeData));
}
}

2
lib/chat/chat.dart Normal file
View File

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

View File

@ -0,0 +1,11 @@
import 'package:bloc_tools/bloc_tools.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:veilid_support/veilid_support.dart';
class ActiveChatCubit extends Cubit<TypedKey?> with BlocTools {
ActiveChatCubit(super.initialState);
void setActiveChat(TypedKey? activeChatRemoteConversationRecordKey) {
emit(activeChatRemoteConversationRecordKey);
}
}

View File

@ -0,0 +1,2 @@
export 'active_chat_cubit.dart';
export 'single_contact_messages_cubit.dart';

View File

@ -0,0 +1,278 @@
import 'dart:async';
import 'package:async_tools/async_tools.dart';
import 'package:bloc_tools/bloc_tools.dart';
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:veilid_support/veilid_support.dart';
import '../../account_manager/account_manager.dart';
import '../../proto/proto.dart' as proto;
class _SingleContactMessageQueueEntry {
_SingleContactMessageQueueEntry({this.localMessages, this.remoteMessages});
IList<proto.Message>? localMessages;
IList<proto.Message>? remoteMessages;
}
typedef SingleContactMessagesState = AsyncValue<IList<proto.Message>>;
// Cubit that processes single-contact chats
// Builds the reconciled chat record from the local and remote conversation keys
class SingleContactMessagesCubit extends Cubit<SingleContactMessagesState> {
SingleContactMessagesCubit({
required ActiveAccountInfo activeAccountInfo,
required TypedKey remoteIdentityPublicKey,
required TypedKey localConversationRecordKey,
required TypedKey localMessagesRecordKey,
required TypedKey remoteConversationRecordKey,
required TypedKey remoteMessagesRecordKey,
required OwnedDHTRecordPointer reconciledChatRecord,
}) : _activeAccountInfo = activeAccountInfo,
_remoteIdentityPublicKey = remoteIdentityPublicKey,
_localConversationRecordKey = localConversationRecordKey,
_localMessagesRecordKey = localMessagesRecordKey,
_remoteConversationRecordKey = remoteConversationRecordKey,
_remoteMessagesRecordKey = remoteMessagesRecordKey,
_reconciledChatRecord = reconciledChatRecord,
_messagesUpdateQueue = StreamController(),
super(const AsyncValue.loading()) {
// Async Init
Future.delayed(Duration.zero, _init);
}
@override
Future<void> close() async {
await _messagesUpdateQueue.close();
await _localSubscription?.cancel();
await _remoteSubscription?.cancel();
await _reconciledChatSubscription?.cancel();
await _localMessagesCubit?.close();
await _remoteMessagesCubit?.close();
await _reconciledChatMessagesCubit?.close();
await super.close();
}
// Initialize everything
Future<void> _init() async {
// Make crypto
await _initMessagesCrypto();
// Reconciled messages key
await _initReconciledChatMessages();
// Local messages key
await _initLocalMessages();
// Remote messages key
await _initRemoteMessages();
// Messages listener
Future.delayed(Duration.zero, () async {
await for (final entry in _messagesUpdateQueue.stream) {
await _updateMessagesStateAsync(entry);
}
});
}
// Make crypto
Future<void> _initMessagesCrypto() async {
_messagesCrypto = await _activeAccountInfo
.makeConversationCrypto(_remoteIdentityPublicKey);
}
// Open local messages key
Future<void> _initLocalMessages() async {
final writer = _activeAccountInfo.conversationWriter;
_localMessagesCubit = DHTShortArrayCubit(
open: () async => DHTShortArray.openWrite(
_localMessagesRecordKey, writer,
parent: _localConversationRecordKey, crypto: _messagesCrypto),
decodeElement: proto.Message.fromBuffer);
_localSubscription =
_localMessagesCubit!.stream.listen(_updateLocalMessagesState);
_updateLocalMessagesState(_localMessagesCubit!.state);
}
// Open remote messages key
Future<void> _initRemoteMessages() async {
_remoteMessagesCubit = DHTShortArrayCubit(
open: () async => DHTShortArray.openRead(_remoteMessagesRecordKey,
parent: _remoteConversationRecordKey, crypto: _messagesCrypto),
decodeElement: proto.Message.fromBuffer);
_remoteSubscription =
_remoteMessagesCubit!.stream.listen(_updateRemoteMessagesState);
_updateRemoteMessagesState(_remoteMessagesCubit!.state);
}
// Open reconciled chat record key
Future<void> _initReconciledChatMessages() async {
final accountRecordKey =
_activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey;
_reconciledChatMessagesCubit = DHTShortArrayCubit(
open: () async => DHTShortArray.openOwned(_reconciledChatRecord,
parent: accountRecordKey),
decodeElement: proto.Message.fromBuffer);
_reconciledChatSubscription =
_reconciledChatMessagesCubit!.stream.listen(_updateReconciledChatState);
_updateReconciledChatState(_reconciledChatMessagesCubit!.state);
}
// Called when the local messages list gets a change
void _updateLocalMessagesState(
BlocBusyState<AsyncValue<IList<proto.Message>>> avmessages) {
final localMessages = avmessages.state.data?.value;
if (localMessages == null) {
return;
}
// Add local messages updates to queue to process asynchronously
_messagesUpdateQueue
.add(_SingleContactMessageQueueEntry(localMessages: localMessages));
}
// Called when the remote messages list gets a change
void _updateRemoteMessagesState(
BlocBusyState<AsyncValue<IList<proto.Message>>> avmessages) {
final remoteMessages = avmessages.state.data?.value;
if (remoteMessages == null) {
return;
}
// Add remote messages updates to queue to process asynchronously
_messagesUpdateQueue
.add(_SingleContactMessageQueueEntry(remoteMessages: remoteMessages));
}
// Called when the reconciled messages list gets a change
void _updateReconciledChatState(
BlocBusyState<AsyncValue<IList<proto.Message>>> avmessages) {
// When reconciled messages are updated, pass this
// directly to the messages cubit state
emit(avmessages.state);
}
Future<void> _mergeMessagesInner(
{required DHTShortArrayWrite reconciledMessagesWriter,
required IList<proto.Message> messages}) async {
// Ensure remoteMessages is sorted by timestamp
final newMessages = messages
.sort((a, b) => a.timestamp.compareTo(b.timestamp))
.removeDuplicates();
// Existing messages will always be sorted by timestamp so merging is easy
final existingMessages = await reconciledMessagesWriter
.getAllItemsProtobuf(proto.Message.fromBuffer);
if (existingMessages == null) {
throw Exception(
'Could not load existing reconciled messages at this time');
}
var ePos = 0;
var nPos = 0;
while (ePos < existingMessages.length && nPos < newMessages.length) {
final existingMessage = existingMessages[ePos];
final newMessage = newMessages[nPos];
// If timestamp to insert is less than
// the current position, insert it here
final newTs = Timestamp.fromInt64(newMessage.timestamp);
final existingTs = Timestamp.fromInt64(existingMessage.timestamp);
final cmp = newTs.compareTo(existingTs);
if (cmp < 0) {
// New message belongs here
// Insert into dht backing array
await reconciledMessagesWriter.tryInsertItem(
ePos, newMessage.writeToBuffer());
// Insert into local copy as well for this operation
existingMessages.insert(ePos, newMessage);
// Next message
nPos++;
ePos++;
} else if (cmp == 0) {
// Duplicate, skip
nPos++;
ePos++;
} else if (cmp > 0) {
// New message belongs later
ePos++;
}
}
// If there are any new messages left, append them all
while (nPos < newMessages.length) {
final newMessage = newMessages[nPos];
// Append to dht backing array
await reconciledMessagesWriter.tryAddItem(newMessage.writeToBuffer());
// Insert into local copy as well for this operation
existingMessages.add(newMessage);
nPos++;
}
}
Future<void> _updateMessagesStateAsync(
_SingleContactMessageQueueEntry entry) async {
final reconciledChatMessagesCubit = _reconciledChatMessagesCubit!;
// Merge remote and local messages into the reconciled chat log
await reconciledChatMessagesCubit
.operateWrite((reconciledMessagesWriter) async {
// xxx for now, keep two lists, but can probable simplify this out soon
if (entry.localMessages != null) {
await _mergeMessagesInner(
reconciledMessagesWriter: reconciledMessagesWriter,
messages: entry.localMessages!);
}
if (entry.remoteMessages != null) {
await _mergeMessagesInner(
reconciledMessagesWriter: reconciledMessagesWriter,
messages: entry.remoteMessages!);
}
});
}
// Force refresh of messages
Future<void> refresh() async {
final lcc = _localMessagesCubit;
final rcc = _remoteMessagesCubit;
if (lcc != null) {
await lcc.refresh();
}
if (rcc != null) {
await rcc.refresh();
}
}
Future<void> addMessage({required proto.Message message}) async {
await _localMessagesCubit!
.operateWrite((writer) => writer.tryAddItem(message.writeToBuffer()));
}
final ActiveAccountInfo _activeAccountInfo;
final TypedKey _remoteIdentityPublicKey;
final TypedKey _localConversationRecordKey;
final TypedKey _localMessagesRecordKey;
final TypedKey _remoteConversationRecordKey;
final TypedKey _remoteMessagesRecordKey;
final OwnedDHTRecordPointer _reconciledChatRecord;
late final DHTRecordCrypto _messagesCrypto;
DHTShortArrayCubit<proto.Message>? _localMessagesCubit;
DHTShortArrayCubit<proto.Message>? _remoteMessagesCubit;
DHTShortArrayCubit<proto.Message>? _reconciledChatMessagesCubit;
final StreamController<_SingleContactMessageQueueEntry> _messagesUpdateQueue;
StreamSubscription<BlocBusyState<AsyncValue<IList<proto.Message>>>>?
_localSubscription;
StreamSubscription<BlocBusyState<AsyncValue<IList<proto.Message>>>>?
_remoteSubscription;
StreamSubscription<BlocBusyState<AsyncValue<IList<proto.Message>>>>?
_reconciledChatSubscription;
}

View File

@ -0,0 +1,211 @@
import 'dart:async';
import 'package:async_tools/async_tools.dart';
import 'package:awesome_extensions/awesome_extensions.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_types/flutter_chat_types.dart' as types;
import 'package:flutter_chat_ui/flutter_chat_ui.dart';
import 'package:veilid_support/veilid_support.dart';
import '../../account_manager/account_manager.dart';
import '../../chat_list/chat_list.dart';
import '../../contacts/contacts.dart';
import '../../proto/proto.dart' as proto;
import '../../theme/theme.dart';
import '../../tools/tools.dart';
import '../chat.dart';
class ChatComponent extends StatelessWidget {
const ChatComponent._(
{required TypedKey localUserIdentityKey,
required SingleContactMessagesCubit messagesCubit,
required SingleContactMessagesState messagesState,
required types.User localUser,
required types.User remoteUser,
super.key})
: _localUserIdentityKey = localUserIdentityKey,
_messagesCubit = messagesCubit,
_messagesState = messagesState,
_localUser = localUser,
_remoteUser = remoteUser;
final TypedKey _localUserIdentityKey;
final SingleContactMessagesCubit _messagesCubit;
final SingleContactMessagesState _messagesState;
final types.User _localUser;
final types.User _remoteUser;
// Builder wrapper function that takes care of state management requirements
static Widget builder(
{required TypedKey remoteConversationRecordKey, Key? key}) =>
Builder(builder: (context) {
// Get all watched dependendies
final activeAccountInfo = context.watch<ActiveAccountInfo>();
final accountRecordInfo =
context.watch<AccountRecordCubit>().state.data?.value;
if (accountRecordInfo == null) {
return debugPage('should always have an account record here');
}
final contactList =
context.watch<ContactListCubit>().state.state.data?.value;
if (contactList == null) {
return debugPage('should always have a contact list here');
}
final avconversation = context.select<ActiveConversationsBlocMapCubit,
AsyncValue<ActiveConversationState>?>(
(x) => x.state[remoteConversationRecordKey]);
if (avconversation == null) {
return waitingPage();
}
final conversation = avconversation.data?.value;
if (conversation == null) {
return avconversation.buildNotData();
}
// Make flutter_chat_ui 'User's
final localUserIdentityKey = activeAccountInfo
.localAccount.identityMaster
.identityPublicTypedKey();
final localUser = types.User(
id: localUserIdentityKey.toString(),
firstName: accountRecordInfo.profile.name,
);
final editedName = conversation.contact.editedProfile.name;
final remoteUser = types.User(
id: conversation.contact.identityPublicKey.toVeilid().toString(),
firstName: editedName);
// Get the messages cubit
final messages = context.select<ActiveSingleContactChatBlocMapCubit,
(SingleContactMessagesCubit, SingleContactMessagesState)?>(
(x) => x.tryOperate(remoteConversationRecordKey,
closure: (cubit) => (cubit, cubit.state)));
// Get the messages to display
// and ensure it is safe to operate() on the MessageCubit for this chat
if (messages == null) {
return waitingPage();
}
return ChatComponent._(
localUserIdentityKey: localUserIdentityKey,
messagesCubit: messages.$1,
messagesState: messages.$2,
localUser: localUser,
remoteUser: remoteUser,
key: key);
});
/////////////////////////////////////////////////////////////////////
types.Message messageToChatMessage(proto.Message message) {
final isLocal = message.author == _localUserIdentityKey.toProto();
final textMessage = types.TextMessage(
author: isLocal ? _localUser : _remoteUser,
createdAt: (message.timestamp ~/ 1000).toInt(),
id: message.timestamp.toString(),
text: message.text,
);
return textMessage;
}
Future<void> _addMessage(proto.Message message) async {
if (message.text.isEmpty) {
return;
}
await _messagesCubit.addMessage(message: message);
}
Future<void> _handleSendPressed(types.PartialText message) async {
final protoMessage = proto.Message()
..author = _localUserIdentityKey.toProto()
..timestamp = Veilid.instance.now().toInt64()
..text = message.text;
//..signature = signature;
await _addMessage(protoMessage);
}
Future<void> _handleAttachmentPressed() async {
//
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scale = theme.extension<ScaleScheme>()!;
final textTheme = Theme.of(context).textTheme;
final chatTheme = makeChatTheme(scale, textTheme);
final messages = _messagesState.data?.value;
if (messages == null) {
return _messagesState.buildNotData();
}
// Convert protobuf messages to chat messages
final chatMessages = <types.Message>[];
for (final message in messages) {
final chatMessage = messageToChatMessage(message);
chatMessages.insert(0, chatMessage);
}
return DefaultTextStyle(
style: textTheme.bodySmall!,
child: Align(
alignment: AlignmentDirectional.centerEnd,
child: Stack(
children: [
Column(
children: [
Container(
height: 48,
decoration: BoxDecoration(
color: scale.primaryScale.subtleBorder,
),
child: Row(children: [
Align(
alignment: AlignmentDirectional.centerStart,
child: Padding(
padding: const EdgeInsetsDirectional.fromSTEB(
16, 0, 16, 0),
child: Text(_remoteUser.firstName!,
textAlign: TextAlign.start,
style: textTheme.titleMedium),
)),
const Spacer(),
IconButton(
icon: const Icon(Icons.close),
onPressed: () async {
context.read<ActiveChatCubit>().setActiveChat(null);
}).paddingLTRB(16, 0, 16, 0)
]),
),
Expanded(
child: DecoratedBox(
decoration: const BoxDecoration(),
child: Chat(
theme: chatTheme,
messages: chatMessages,
//onAttachmentPressed: _handleAttachmentPressed,
//onMessageTap: _handleMessageTap,
//onPreviewDataFetched: _handlePreviewDataFetched,
onSendPressed: (message) {
singleFuture(
this, () async => _handleSendPressed(message));
},
//showUserAvatars: false,
//showUserNames: true,
user: _localUser,
),
),
),
],
),
],
),
));
}
}

View File

@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
class EmptyChatWidget extends StatelessWidget {
const EmptyChatWidget({super.key});
@override
// ignore: prefer_expression_function_bodies
Widget build(
BuildContext context,
) =>
Container(
width: double.infinity,
height: double.infinity,
decoration: BoxDecoration(
color: Theme.of(context).scaffoldBackgroundColor,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.chat,
color: Theme.of(context).disabledColor,
size: 48,
),
Text(
'Say Something',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).disabledColor,
),
),
],
),
);
}

View File

@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
class NoConversationWidget extends StatelessWidget {
const NoConversationWidget({super.key});
@override
// ignore: prefer_expression_function_bodies
Widget build(
BuildContext context,
) =>
Container(
width: double.infinity,
height: double.infinity,
decoration: BoxDecoration(
color: Theme.of(context).primaryColor,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.emoji_people_outlined,
color: Theme.of(context).disabledColor,
size: 48,
),
Text(
'Choose A Conversation To Chat',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).disabledColor,
),
),
],
),
);
}

View File

@ -0,0 +1,3 @@
export 'chat_component.dart';
export 'empty_chat_widget.dart';
export 'no_conversation_widget.dart';

View File

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

View File

@ -0,0 +1,116 @@
import 'package:async_tools/async_tools.dart';
import 'package:bloc_tools/bloc_tools.dart';
import 'package:equatable/equatable.dart';
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:meta/meta.dart';
import 'package:veilid_support/veilid_support.dart';
import '../../account_manager/account_manager.dart';
import '../../contacts/contacts.dart';
import '../../proto/proto.dart' as proto;
@immutable
class ActiveConversationState extends Equatable {
const ActiveConversationState({
required this.contact,
required this.localConversation,
required this.remoteConversation,
});
final proto.Contact contact;
final proto.Conversation localConversation;
final proto.Conversation remoteConversation;
@override
List<Object?> get props => [contact, localConversation, remoteConversation];
}
typedef ActiveConversationCubit = TransformerCubit<
AsyncValue<ActiveConversationState>, AsyncValue<ConversationState>>;
typedef ActiveConversationsBlocMapState
= BlocMapState<TypedKey, AsyncValue<ActiveConversationState>>;
// Map of remoteConversationRecordKey to ActiveConversationCubit
// Wraps a conversation cubit to only expose completely built conversations
// Automatically follows the state of a ChatListCubit.
// Even though 'conversations' are per-contact and not per-chat
// We currently only build the cubits for the chats that are active, not
// archived chats or contacts that are not actively in a chat.
class ActiveConversationsBlocMapCubit extends BlocMapCubit<TypedKey,
AsyncValue<ActiveConversationState>, ActiveConversationCubit>
with
StateFollower<BlocBusyState<AsyncValue<IList<proto.Chat>>>, TypedKey,
proto.Chat> {
ActiveConversationsBlocMapCubit(
{required ActiveAccountInfo activeAccountInfo,
required ContactListCubit contactListCubit})
: _activeAccountInfo = activeAccountInfo,
_contactListCubit = contactListCubit;
// Add an active conversation to be tracked for changes
Future<void> _addConversation({required proto.Contact contact}) async =>
add(() => MapEntry(
contact.remoteConversationRecordKey.toVeilid(),
TransformerCubit(
ConversationCubit(
activeAccountInfo: _activeAccountInfo,
remoteIdentityPublicKey: contact.identityPublicKey.toVeilid(),
localConversationRecordKey:
contact.localConversationRecordKey.toVeilid(),
remoteConversationRecordKey:
contact.remoteConversationRecordKey.toVeilid(),
),
// Transformer that only passes through completed conversations
// along with the contact that corresponds to the completed
// conversation
transform: (avstate) => avstate.when(
data: (data) => (data.localConversation == null ||
data.remoteConversation == null)
? const AsyncValue.loading()
: AsyncValue.data(ActiveConversationState(
contact: contact,
localConversation: data.localConversation!,
remoteConversation: data.remoteConversation!)),
loading: AsyncValue.loading,
error: AsyncValue.error))));
/// StateFollower /////////////////////////
@override
IMap<TypedKey, proto.Chat> getStateMap(
BlocBusyState<AsyncValue<IList<proto.Chat>>> state) {
final stateValue = state.state.data?.value;
if (stateValue == null) {
return IMap();
}
return IMap.fromIterable(stateValue,
keyMapper: (e) => e.remoteConversationRecordKey.toVeilid(),
valueMapper: (e) => e);
}
@override
Future<void> removeFromState(TypedKey key) => remove(key);
@override
Future<void> updateState(TypedKey key, proto.Chat value) async {
final contactList = _contactListCubit.state.state.data?.value;
if (contactList == null) {
await addState(key, const AsyncValue.loading());
return;
}
final contactIndex = contactList
.indexWhere((c) => c.remoteConversationRecordKey.toVeilid() == key);
if (contactIndex == -1) {
await addState(key, AsyncValue.error('Contact not found'));
return;
}
final contact = contactList[contactIndex];
await _addConversation(contact: contact);
}
////
final ActiveAccountInfo _activeAccountInfo;
final ContactListCubit _contactListCubit;
}

View File

@ -0,0 +1,108 @@
import 'dart:async';
import 'package:async_tools/async_tools.dart';
import 'package:bloc_tools/bloc_tools.dart';
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:veilid_support/veilid_support.dart';
import '../../account_manager/account_manager.dart';
import '../../chat/chat.dart';
import '../../contacts/contacts.dart';
import '../../proto/proto.dart' as proto;
import 'active_conversations_bloc_map_cubit.dart';
import 'chat_list_cubit.dart';
// Map of remoteConversationRecordKey to MessagesCubit
// Wraps a MessagesCubit to stream the latest messages to the state
// Automatically follows the state of a ActiveConversationsBlocMapCubit.
class ActiveSingleContactChatBlocMapCubit extends BlocMapCubit<TypedKey,
AsyncValue<IList<proto.Message>>, SingleContactMessagesCubit>
with
StateFollower<ActiveConversationsBlocMapState, TypedKey,
AsyncValue<ActiveConversationState>> {
ActiveSingleContactChatBlocMapCubit(
{required ActiveAccountInfo activeAccountInfo,
required ContactListCubit contactListCubit,
required ChatListCubit chatListCubit})
: _activeAccountInfo = activeAccountInfo,
_contactListCubit = contactListCubit,
_chatListCubit = chatListCubit;
Future<void> _addConversationMessages(
{required proto.Contact contact,
required proto.Chat chat,
required proto.Conversation localConversation,
required proto.Conversation remoteConversation}) async =>
add(() => MapEntry(
contact.remoteConversationRecordKey.toVeilid(),
SingleContactMessagesCubit(
activeAccountInfo: _activeAccountInfo,
remoteIdentityPublicKey: contact.identityPublicKey.toVeilid(),
localConversationRecordKey:
contact.localConversationRecordKey.toVeilid(),
remoteConversationRecordKey:
contact.remoteConversationRecordKey.toVeilid(),
localMessagesRecordKey: localConversation.messages.toVeilid(),
remoteMessagesRecordKey: remoteConversation.messages.toVeilid(),
reconciledChatRecord: chat.reconciledChatRecord.toVeilid(),
)));
/// StateFollower /////////////////////////
@override
IMap<TypedKey, AsyncValue<ActiveConversationState>> getStateMap(
ActiveConversationsBlocMapState state) =>
state;
@override
Future<void> removeFromState(TypedKey key) => remove(key);
@override
Future<void> updateState(
TypedKey key, AsyncValue<ActiveConversationState> value) async {
// Get the contact object for this single contact chat
final contactList = _contactListCubit.state.state.data?.value;
if (contactList == null) {
await addState(key, const AsyncValue.loading());
return;
}
final contactIndex = contactList
.indexWhere((c) => c.remoteConversationRecordKey.toVeilid() == key);
if (contactIndex == -1) {
await addState(
key, AsyncValue.error('Contact not found for conversation'));
return;
}
final contact = contactList[contactIndex];
// Get the chat object for this single contact chat
final chatList = _chatListCubit.state.state.data?.value;
if (chatList == null) {
await addState(key, const AsyncValue.loading());
return;
}
final chatIndex = chatList
.indexWhere((c) => c.remoteConversationRecordKey.toVeilid() == key);
if (contactIndex == -1) {
await addState(key, AsyncValue.error('Chat not found for conversation'));
return;
}
final chat = chatList[chatIndex];
await value.when(
data: (state) => _addConversationMessages(
contact: contact,
chat: chat,
localConversation: state.localConversation,
remoteConversation: state.remoteConversation),
loading: () => addState(key, const AsyncValue.loading()),
error: (error, stackTrace) =>
addState(key, AsyncValue.error(error, stackTrace)));
}
////
final ActiveAccountInfo _activeAccountInfo;
final ContactListCubit _contactListCubit;
final ChatListCubit _chatListCubit;
}

View File

@ -0,0 +1,121 @@
import 'dart:async';
import 'package:veilid_support/veilid_support.dart';
import '../../account_manager/account_manager.dart';
import '../../chat/chat.dart';
import '../../proto/proto.dart' as proto;
import '../../tools/tools.dart';
//////////////////////////////////////////////////
//////////////////////////////////////////////////
// Mutable state for per-account chat list
class ChatListCubit extends DHTShortArrayCubit<proto.Chat> {
ChatListCubit({
required ActiveAccountInfo activeAccountInfo,
required proto.Account account,
required this.activeChatCubit,
}) : _activeAccountInfo = activeAccountInfo,
super(
open: () => _open(activeAccountInfo, account),
decodeElement: proto.Chat.fromBuffer);
static Future<DHTShortArray> _open(
ActiveAccountInfo activeAccountInfo, proto.Account account) async {
final accountRecordKey =
activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey;
final chatListRecordKey = account.chatList.toVeilid();
final dhtRecord = await DHTShortArray.openOwned(chatListRecordKey,
parent: accountRecordKey);
return dhtRecord;
}
/// Create a new chat (singleton for single contact chats)
Future<void> getOrCreateChatSingleContact({
required TypedKey remoteConversationRecordKey,
}) async {
// Add Chat to account's list
// if this fails, don't keep retrying, user can try again later
await operateWrite((writer) async {
final remoteConversationRecordKeyProto =
remoteConversationRecordKey.toProto();
// See if we have added this chat already
for (var i = 0; i < writer.length; i++) {
final cbuf = await writer.getItem(i);
if (cbuf == null) {
throw Exception('Failed to get chat');
}
final c = proto.Chat.fromBuffer(cbuf);
if (c.remoteConversationRecordKey == remoteConversationRecordKeyProto) {
// Nothing to do here
return;
}
}
final accountRecordKey = _activeAccountInfo
.userLogin.accountRecordInfo.accountRecord.recordKey;
// Make a record that can store the reconciled version of the chat
final reconciledChatRecord =
await (await DHTShortArray.create(parent: accountRecordKey))
.scope((r) async => r.recordPointer);
// Create conversation type Chat
final chat = proto.Chat()
..type = proto.ChatType.SINGLE_CONTACT
..remoteConversationRecordKey = remoteConversationRecordKeyProto
..reconciledChatRecord = reconciledChatRecord.toProto();
// Add chat
final added = await writer.tryAddItem(chat.writeToBuffer());
if (!added) {
throw Exception('Failed to add chat');
}
});
}
/// Delete a chat
Future<void> deleteChat(
{required TypedKey remoteConversationRecordKey}) async {
final remoteConversationKey = remoteConversationRecordKey.toProto();
// Remove Chat from account's list
// if this fails, don't keep retrying, user can try again later
final (deletedItem, success) = await operateWrite((writer) async {
if (activeChatCubit.state == remoteConversationRecordKey) {
activeChatCubit.setActiveChat(null);
}
for (var i = 0; i < writer.length; i++) {
final cbuf = await writer.getItem(i);
if (cbuf == null) {
throw Exception('Failed to get chat');
}
final c = proto.Chat.fromBuffer(cbuf);
if (c.remoteConversationRecordKey == remoteConversationKey) {
// Found the right chat
if (await writer.tryRemoveItem(i) != null) {
return c;
}
return null;
}
}
return null;
});
if (success && deletedItem != null) {
try {
await DHTRecordPool.instance
.delete(deletedItem.reconciledChatRecord.toVeilid().recordKey);
} on Exception catch (e) {
log.debug('error removing reconciled chat record: $e', e);
}
}
}
final ActiveChatCubit activeChatCubit;
final ActiveAccountInfo _activeAccountInfo;
}

View File

@ -0,0 +1,3 @@
export 'active_single_contact_chat_bloc_map_cubit.dart';
export 'active_conversations_bloc_map_cubit.dart';
export 'chat_list_cubit.dart';

View File

@ -1,29 +1,38 @@
import 'package:async_tools/async_tools.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:flutter_translate/flutter_translate.dart';
import '../proto/proto.dart' as proto;
import '../providers/account.dart';
import '../providers/chat.dart';
import '../tools/theme_service.dart';
import '../../chat/cubits/active_chat_cubit.dart';
import '../../proto/proto.dart' as proto;
import '../../theme/theme.dart';
import '../chat_list.dart';
class ChatSingleContactItemWidget extends ConsumerWidget {
const ChatSingleContactItemWidget({required this.contact, super.key});
class ChatSingleContactItemWidget extends StatelessWidget {
const ChatSingleContactItemWidget({
required proto.Contact contact,
required bool disabled,
super.key,
}) : _contact = contact,
_disabled = disabled;
final proto.Contact contact;
final proto.Contact _contact;
final bool _disabled;
@override
// ignore: prefer_expression_function_bodies
Widget build(BuildContext context, WidgetRef ref) {
Widget build(
BuildContext context,
) {
final theme = Theme.of(context);
//final textTheme = theme.textTheme;
final scale = theme.extension<ScaleScheme>()!;
final activeChat = ref.watch(activeChatStateProvider);
final activeChatCubit = context.watch<ActiveChatCubit>();
final remoteConversationRecordKey =
proto.TypedKeyProto.fromProto(contact.remoteConversationRecordKey);
final selected = activeChat == remoteConversationRecordKey;
_contact.remoteConversationRecordKey.toVeilid();
final selected = activeChatCubit.state == remoteConversationRecordKey;
return Container(
margin: const EdgeInsets.fromLTRB(0, 4, 0, 0),
@ -34,21 +43,18 @@ class ChatSingleContactItemWidget extends ConsumerWidget {
borderRadius: BorderRadius.circular(8),
)),
child: Slidable(
key: ObjectKey(contact),
key: ObjectKey(_contact),
endActionPane: ActionPane(
motion: const DrawerMotion(),
children: [
SlidableAction(
onPressed: (context) async {
final activeAccountInfo =
await ref.read(fetchActiveAccountProvider.future);
if (activeAccountInfo != null) {
await deleteChat(
activeAccountInfo: activeAccountInfo,
onPressed: _disabled
? null
: (context) async {
final chatListCubit = context.read<ChatListCubit>();
await chatListCubit.deleteChat(
remoteConversationRecordKey:
remoteConversationRecordKey);
ref.invalidate(fetchChatListProvider);
}
},
backgroundColor: scale.tertiaryScale.background,
foregroundColor: scale.tertiaryScale.text,
@ -68,16 +74,19 @@ class ChatSingleContactItemWidget extends ConsumerWidget {
// The child of the Slidable is what the user sees when the
// component is not dragged.
child: ListTile(
onTap: () async {
ref.read(activeChatStateProvider.notifier).state =
remoteConversationRecordKey;
ref.invalidate(fetchChatListProvider);
onTap: _disabled
? null
: () {
singleFuture(activeChatCubit, () async {
activeChatCubit
.setActiveChat(remoteConversationRecordKey);
});
},
title: Text(contact.editedProfile.name),
title: Text(_contact.editedProfile.name),
/// xxx show last message here
subtitle: (contact.editedProfile.pronouns.isNotEmpty)
? Text(contact.editedProfile.pronouns)
subtitle: (_contact.editedProfile.pronouns.isNotEmpty)
? Text(_contact.editedProfile.pronouns)
: null,
iconColor: scale.tertiaryScale.background,
textColor: scale.tertiaryScale.text,
@ -89,6 +98,6 @@ class ChatSingleContactItemWidget extends ConsumerWidget {
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<proto.Contact>('contact', contact));
properties.add(DiagnosticsProperty<proto.Contact>('contact', _contact));
}
}

View File

@ -0,0 +1,83 @@
import 'package:awesome_extensions/awesome_extensions.dart';
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_translate/flutter_translate.dart';
import 'package:searchable_listview/searchable_listview.dart';
import '../../contacts/contacts.dart';
import '../../proto/proto.dart' as proto;
import '../../theme/theme.dart';
import '../../tools/tools.dart';
import '../chat_list.dart';
class ChatSingleContactListWidget extends StatelessWidget {
const ChatSingleContactListWidget({super.key});
@override
// ignore: prefer_expression_function_bodies
Widget build(BuildContext context) {
final theme = Theme.of(context);
//final textTheme = theme.textTheme;
final scale = theme.extension<ScaleScheme>()!;
final contactListV = context.watch<ContactListCubit>().state;
return contactListV.builder((context, contactList) {
final contactMap = IMap.fromIterable(contactList,
keyMapper: (c) => c.remoteConversationRecordKey,
valueMapper: (c) => c);
final chatListV = context.watch<ChatListCubit>().state;
return chatListV.builder((context, chatList) => SizedBox.expand(
child: styledTitleContainer(
context: context,
title: translate('chat_list.chats'),
child: SizedBox.expand(
child: (chatList.isEmpty)
? const EmptyChatListWidget()
: SearchableList<proto.Chat>(
initialList: chatList.toList(),
builder: (l, i, c) {
final contact =
contactMap[c.remoteConversationRecordKey];
if (contact == null) {
return const Text('...');
}
return ChatSingleContactItemWidget(
contact: contact,
disabled: contactListV.busy);
},
filter: (value) {
final lowerValue = value.toLowerCase();
return chatList.where((c) {
final contact =
contactMap[c.remoteConversationRecordKey];
if (contact == null) {
return false;
}
return contact.editedProfile.name
.toLowerCase()
.contains(lowerValue) ||
contact.editedProfile.pronouns
.toLowerCase()
.contains(lowerValue);
}).toList();
},
spaceBetweenSearchAndList: 4,
inputDecoration: InputDecoration(
labelText: translate('chat_list.search'),
contentPadding: const EdgeInsets.all(2),
fillColor: scale.primaryScale.text,
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(
color: scale.primaryScale.hoverBorder,
),
borderRadius: BorderRadius.circular(8),
),
),
).paddingAll(8))))
.paddingLTRB(8, 8, 8, 65));
});
}
}

View File

@ -1,15 +1,14 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_translate/flutter_translate.dart';
import '../tools/tools.dart';
import '../../theme/theme.dart';
class EmptyChatListWidget extends ConsumerWidget {
class EmptyChatListWidget extends StatelessWidget {
const EmptyChatListWidget({super.key});
@override
// ignore: prefer_expression_function_bodies
Widget build(BuildContext context, WidgetRef ref) {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final textTheme = theme.textTheme;
final scale = theme.extension<ScaleScheme>()!;

View File

@ -0,0 +1,3 @@
export 'chat_single_contact_item_widget.dart';
export 'chat_single_contact_list_widget.dart';
export 'empty_chat_list_widget.dart';

View File

@ -1,55 +0,0 @@
import 'package:awesome_extensions/awesome_extensions.dart';
import 'package:circular_profile_avatar/circular_profile_avatar.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:window_manager/window_manager.dart';
import '../entities/local_account.dart';
import '../providers/logins.dart';
class AccountBubble extends ConsumerWidget {
const AccountBubble({required this.account, super.key});
final LocalAccount account;
@override
Widget build(BuildContext context, WidgetRef ref) {
windowManager.setTitleBarStyle(TitleBarStyle.normal);
final logins = ref.watch(loginsProvider);
return ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 300),
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
Expanded(
flex: 4,
child: CircularProfileAvatar('',
child: Container(color: Theme.of(context).disabledColor))),
const Expanded(child: Text('Placeholder'))
]));
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<LocalAccount>('account', account));
}
}
class AddAccountBubble extends ConsumerWidget {
const AddAccountBubble({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
windowManager.setTitleBarStyle(TitleBarStyle.normal);
final logins = ref.watch(loginsProvider);
return Column(mainAxisAlignment: MainAxisAlignment.center, children: [
CircularProfileAvatar('',
borderWidth: 4,
borderColor: Theme.of(context).unselectedWidgetColor,
child: Container(
color: Colors.blue, child: const Icon(Icons.add, size: 50))),
const Text('Add Account').paddingLTRB(0, 4, 0, 0)
]);
}
}

View File

@ -1,205 +0,0 @@
import 'dart:async';
import 'package:awesome_extensions/awesome_extensions.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_chat_types/flutter_chat_types.dart' as types;
import 'package:flutter_chat_ui/flutter_chat_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../proto/proto.dart' as proto;
import '../providers/account.dart';
import '../providers/chat.dart';
import '../providers/conversation.dart';
import '../tools/tools.dart';
import '../veilid_init.dart';
import '../veilid_support/veilid_support.dart';
class ChatComponent extends ConsumerStatefulWidget {
const ChatComponent(
{required this.activeAccountInfo,
required this.activeChat,
required this.activeChatContact,
super.key});
final ActiveAccountInfo activeAccountInfo;
final TypedKey activeChat;
final proto.Contact activeChatContact;
@override
ChatComponentState createState() => ChatComponentState();
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties
..add(DiagnosticsProperty<ActiveAccountInfo>(
'activeAccountInfo', activeAccountInfo))
..add(DiagnosticsProperty<TypedKey>('activeChat', activeChat))
..add(DiagnosticsProperty<proto.Contact>(
'activeChatContact', activeChatContact));
}
}
class ChatComponentState extends ConsumerState<ChatComponent> {
final _unfocusNode = FocusNode();
late final types.User _localUser;
late final types.User _remoteUser;
@override
void initState() {
super.initState();
_localUser = types.User(
id: widget.activeAccountInfo.localAccount.identityMaster
.identityPublicTypedKey()
.toString(),
firstName: widget.activeAccountInfo.account.profile.name,
);
_remoteUser = types.User(
id: proto.TypedKeyProto.fromProto(
widget.activeChatContact.identityPublicKey)
.toString(),
firstName: widget.activeChatContact.remoteProfile.name);
}
@override
void dispose() {
_unfocusNode.dispose();
super.dispose();
}
types.Message protoMessageToMessage(proto.Message message) {
final isLocal = message.author ==
widget.activeAccountInfo.localAccount.identityMaster
.identityPublicTypedKey()
.toProto();
final textMessage = types.TextMessage(
author: isLocal ? _localUser : _remoteUser,
createdAt: (message.timestamp ~/ 1000).toInt(),
id: message.timestamp.toString(),
text: message.text,
);
return textMessage;
}
Future<void> _addMessage(proto.Message protoMessage) async {
if (protoMessage.text.isEmpty) {
return;
}
final message = protoMessageToMessage(protoMessage);
// setState(() {
// _messages.insert(0, message);
// });
// Now add the message to the conversation messages
final localConversationRecordKey = proto.TypedKeyProto.fromProto(
widget.activeChatContact.localConversationRecordKey);
final remoteIdentityPublicKey = proto.TypedKeyProto.fromProto(
widget.activeChatContact.identityPublicKey);
await addLocalConversationMessage(
activeAccountInfo: widget.activeAccountInfo,
localConversationRecordKey: localConversationRecordKey,
remoteIdentityPublicKey: remoteIdentityPublicKey,
message: protoMessage);
ref.invalidate(activeConversationMessagesProvider);
}
Future<void> _handleSendPressed(types.PartialText message) async {
final protoMessage = proto.Message()
..author = widget.activeAccountInfo.localAccount.identityMaster
.identityPublicTypedKey()
.toProto()
..timestamp = (await eventualVeilid.future).now().toInt64()
..text = message.text;
//..signature = signature;
await _addMessage(protoMessage);
}
void _handleAttachmentPressed() {
//
}
@override
// ignore: prefer_expression_function_bodies
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scale = theme.extension<ScaleScheme>()!;
final textTheme = Theme.of(context).textTheme;
final chatTheme = makeChatTheme(scale, textTheme);
final contactName = widget.activeChatContact.editedProfile.name;
final protoMessages =
ref.watch(activeConversationMessagesProvider).asData?.value;
if (protoMessages == null) {
return waitingPage(context);
}
final messages = <types.Message>[];
for (final protoMessage in protoMessages) {
final message = protoMessageToMessage(protoMessage);
messages.insert(0, message);
}
return DefaultTextStyle(
style: textTheme.bodySmall!,
child: Align(
alignment: AlignmentDirectional.centerEnd,
child: Stack(
children: [
Column(
children: [
Container(
height: 48,
decoration: BoxDecoration(
color: scale.primaryScale.subtleBorder,
),
child: Row(children: [
Align(
alignment: AlignmentDirectional.centerStart,
child: Padding(
padding: const EdgeInsetsDirectional.fromSTEB(
16, 0, 16, 0),
child: Text(contactName,
textAlign: TextAlign.start,
style: textTheme.titleMedium),
)),
const Spacer(),
IconButton(
icon: const Icon(Icons.close),
onPressed: () async {
ref.read(activeChatStateProvider.notifier).state =
null;
}).paddingLTRB(16, 0, 16, 0)
]),
),
Expanded(
child: DecoratedBox(
decoration: const BoxDecoration(),
child: Chat(
theme: chatTheme,
messages: messages,
//onAttachmentPressed: _handleAttachmentPressed,
//onMessageTap: _handleMessageTap,
//onPreviewDataFetched: _handlePreviewDataFetched,
onSendPressed: (message) {
unawaited(_handleSendPressed(message));
},
//showUserAvatars: false,
//showUserNames: true,
user: _localUser,
),
),
),
],
),
],
),
));
}
}

View File

@ -1,92 +0,0 @@
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 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_translate/flutter_translate.dart';
import 'package:searchable_listview/searchable_listview.dart';
import '../proto/proto.dart' as proto;
import '../tools/tools.dart';
import 'chat_single_contact_item_widget.dart';
import 'empty_chat_list_widget.dart';
class ChatSingleContactListWidget extends ConsumerWidget {
ChatSingleContactListWidget(
{required IList<proto.Contact> contactList,
required this.chatList,
super.key})
: contactMap = IMap.fromIterable(contactList,
keyMapper: (c) => c.remoteConversationRecordKey,
valueMapper: (c) => c);
final IMap<proto.TypedKey, proto.Contact> contactMap;
final IList<proto.Chat> chatList;
@override
// ignore: prefer_expression_function_bodies
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
//final textTheme = theme.textTheme;
final scale = theme.extension<ScaleScheme>()!;
return SizedBox.expand(
child: styledTitleContainer(
context: context,
title: translate('chat_list.chats'),
child: SizedBox.expand(
child: (chatList.isEmpty)
? const EmptyChatListWidget()
: SearchableList<proto.Chat>(
autoFocusOnSearch: false,
initialList: chatList.toList(),
builder: (l, i, c) {
final contact =
contactMap[c.remoteConversationKey];
if (contact == null) {
return const Text('...');
}
return ChatSingleContactItemWidget(
contact: contact);
},
filter: (value) {
final lowerValue = value.toLowerCase();
return chatList.where((c) {
final contact =
contactMap[c.remoteConversationKey];
if (contact == null) {
return false;
}
return contact.editedProfile.name
.toLowerCase()
.contains(lowerValue) ||
contact.editedProfile.pronouns
.toLowerCase()
.contains(lowerValue);
}).toList();
},
spaceBetweenSearchAndList: 4,
inputDecoration: InputDecoration(
labelText: translate('chat_list.search'),
contentPadding: const EdgeInsets.all(2),
fillColor: scale.primaryScale.text,
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(
color: scale.primaryScale.hoverBorder,
),
borderRadius: BorderRadius.circular(8),
),
),
).paddingAll(8))))
.paddingLTRB(8, 8, 8, 65);
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties
..add(DiagnosticsProperty<IMap<proto.TypedKey, proto.Contact>>(
'contactMap', contactMap))
..add(IterableProperty<proto.Chat>('chatList', chatList));
}
}

View File

@ -1,152 +0,0 @@
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_riverpod/flutter_riverpod.dart';
import 'package:flutter_translate/flutter_translate.dart';
import 'package:qr_flutter/qr_flutter.dart';
import '../tools/tools.dart';
import '../veilid_support/veilid_support.dart';
class ContactInvitationDisplayDialog extends ConsumerStatefulWidget {
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 ConsumerState<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

@ -1,122 +0,0 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:flutter_translate/flutter_translate.dart';
import '../proto/proto.dart' as proto;
import '../pages/main_pager/main_pager.dart';
import '../providers/account.dart';
import '../providers/chat.dart';
import '../providers/contact.dart';
import '../tools/theme_service.dart';
class ContactItemWidget extends ConsumerWidget {
const ContactItemWidget({required this.contact, super.key});
final proto.Contact contact;
@override
// ignore: prefer_expression_function_bodies
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
//final textTheme = theme.textTheme;
final scale = theme.extension<ScaleScheme>()!;
final remoteConversationKey =
proto.TypedKeyProto.fromProto(contact.remoteConversationRecordKey);
return Container(
margin: const EdgeInsets.fromLTRB(0, 4, 0, 0),
clipBehavior: Clip.antiAlias,
decoration: ShapeDecoration(
color: scale.tertiaryScale.subtleBorder,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
)),
child: Slidable(
key: ObjectKey(contact),
endActionPane: ActionPane(
motion: const DrawerMotion(),
children: [
SlidableAction(
onPressed: (context) async {
final activeAccountInfo =
await ref.read(fetchActiveAccountProvider.future);
if (activeAccountInfo != null) {
await deleteContact(
activeAccountInfo: activeAccountInfo,
contact: contact);
ref
..invalidate(fetchContactListProvider)
..invalidate(fetchChatListProvider);
}
},
backgroundColor: scale.tertiaryScale.background,
foregroundColor: scale.tertiaryScale.text,
icon: Icons.delete,
label: translate('button.delete'),
padding: const EdgeInsets.all(2)),
// SlidableAction(
// onPressed: (context) => (),
// backgroundColor: scale.secondaryScale.background,
// foregroundColor: scale.secondaryScale.text,
// icon: Icons.edit,
// label: 'Edit',
// ),
],
),
// The child of the Slidable is what the user sees when the
// component is not dragged.
child: ListTile(
onTap: () async {
final activeAccountInfo =
await ref.read(fetchActiveAccountProvider.future);
if (activeAccountInfo != null) {
// Start a chat
await getOrCreateChatSingleContact(
activeAccountInfo: activeAccountInfo,
remoteConversationRecordKey: remoteConversationKey);
ref
..invalidate(fetchContactListProvider)
..invalidate(fetchChatListProvider);
// Click over to chats
if (context.mounted) {
await MainPager.of(context)?.pageController.animateToPage(
1,
duration: 250.ms,
curve: Curves.easeInOut);
}
}
// // 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(contact.editedProfile.name),
subtitle: (contact.editedProfile.pronouns.isNotEmpty)
? Text(contact.editedProfile.pronouns)
: null,
iconColor: scale.tertiaryScale.background,
textColor: scale.tertiaryScale.text,
//Text(Timestamp.fromInt64(contactInvitationRecord.expiration) / ),
leading: const Icon(Icons.person))));
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<proto.Contact>('contact', contact));
}
}

View File

@ -1,36 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class EmptyChatWidget extends ConsumerWidget {
const EmptyChatWidget({super.key});
@override
// ignore: prefer_expression_function_bodies
Widget build(BuildContext context, WidgetRef ref) {
//
return Container(
width: double.infinity,
height: double.infinity,
decoration: BoxDecoration(
color: Theme.of(context).scaffoldBackgroundColor,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.chat,
color: Theme.of(context).disabledColor,
size: 48,
),
Text(
'Say Something',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).disabledColor,
),
),
],
),
);
}
}

View File

@ -1,35 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class NoContactWidget extends ConsumerWidget {
const NoContactWidget({super.key});
@override
// ignore: prefer_expression_function_bodies
Widget build(BuildContext context, WidgetRef ref) {
//
return Container(
width: double.infinity,
height: double.infinity,
decoration: BoxDecoration(
color: Theme.of(context).primaryColor,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.emoji_people_outlined,
color: Theme.of(context).disabledColor,
size: 48,
),
Text(
'Choose A Conversation To Chat',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).disabledColor,
),
),
],
),
);
}
}

View File

@ -1,49 +0,0 @@
import 'package:awesome_extensions/awesome_extensions.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../tools/tools.dart';
class ProfileWidget extends ConsumerWidget {
const ProfileWidget({
required this.name,
this.pronouns,
super.key,
});
final String name;
final String? pronouns;
@override
// ignore: prefer_expression_function_bodies
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final scale = theme.extension<ScaleScheme>()!;
final textTheme = theme.textTheme;
return DecoratedBox(
decoration: ShapeDecoration(
color: scale.primaryScale.border,
shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))),
child: Column(children: [
Text(
name,
style: textTheme.headlineSmall,
textAlign: TextAlign.left,
).paddingAll(4),
if (pronouns != null && pronouns!.isNotEmpty)
Text(pronouns!, style: textTheme.bodyMedium).paddingLTRB(4, 0, 4, 4),
]),
);
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties
..add(StringProperty('name', name))
..add(StringProperty('pronouns', pronouns));
}
}

View File

@ -1,66 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:signal_strength_indicator/signal_strength_indicator.dart';
import 'package:go_router/go_router.dart';
import '../providers/connection_state.dart';
import '../tools/tools.dart';
import '../veilid_support/veilid_support.dart';
class SignalStrengthMeterWidget extends ConsumerWidget {
const SignalStrengthMeterWidget({super.key});
@override
// ignore: prefer_expression_function_bodies
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final scale = theme.extension<ScaleScheme>()!;
const iconSize = 16.0;
final connState = ref.watch(connectionStateProvider);
late final double value;
late final Color color;
late final Color inactiveColor;
switch (connState.attachment.state) {
case AttachmentState.detached:
return Icon(Icons.signal_cellular_nodata,
size: iconSize, color: scale.grayScale.text);
case AttachmentState.detaching:
return Icon(Icons.signal_cellular_off,
size: iconSize, color: scale.grayScale.text);
case AttachmentState.attaching:
value = 0;
color = scale.primaryScale.text;
case AttachmentState.attachedWeak:
value = 1;
color = scale.primaryScale.text;
case AttachmentState.attachedStrong:
value = 2;
color = scale.primaryScale.text;
case AttachmentState.attachedGood:
value = 3;
color = scale.primaryScale.text;
case AttachmentState.fullyAttached:
value = 4;
color = scale.primaryScale.text;
case AttachmentState.overAttached:
value = 4;
color = scale.secondaryScale.subtleText;
}
inactiveColor = scale.grayScale.subtleText;
return GestureDetector(
onLongPress: () async {
await context.push('/developer');
},
child: SignalStrengthIndicator.bars(
value: value,
activeColor: color,
inactiveColor: inactiveColor,
size: iconSize,
barCount: 4,
spacing: 1,
));
}
}

View File

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

View File

@ -0,0 +1,288 @@
import 'dart:async';
import 'package:fixnum/fixnum.dart';
import 'package:flutter/foundation.dart';
import 'package:veilid_support/veilid_support.dart';
import '../../account_manager/account_manager.dart';
import '../../proto/proto.dart' as proto;
import '../../tools/tools.dart';
import '../models/models.dart';
//////////////////////////////////////////////////
class ContactInviteInvalidKeyException implements Exception {
const ContactInviteInvalidKeyException(this.type) : super();
final EncryptionKeyType type;
}
typedef GetEncryptionKeyCallback = Future<SecretKey?> Function(
VeilidCryptoSystem cs,
EncryptionKeyType encryptionKeyType,
Uint8List encryptedSecret);
//////////////////////////////////////////////////
//////////////////////////////////////////////////
// Mutable state for per-account contact invitations
class ContactInvitationListCubit
extends DHTShortArrayCubit<proto.ContactInvitationRecord> {
ContactInvitationListCubit({
required ActiveAccountInfo activeAccountInfo,
required proto.Account account,
}) : _activeAccountInfo = activeAccountInfo,
_account = account,
super(
open: () => _open(activeAccountInfo, account),
decodeElement: proto.ContactInvitationRecord.fromBuffer);
static Future<DHTShortArray> _open(
ActiveAccountInfo activeAccountInfo, proto.Account account) async {
final accountRecordKey =
activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey;
final contactInvitationListRecordPointer =
account.contactInvitationRecords.toVeilid();
final dhtRecord = await DHTShortArray.openOwned(
contactInvitationListRecordPointer,
parent: accountRecordKey);
return dhtRecord;
}
Future<Uint8List> createInvitation(
{required EncryptionKeyType encryptionKeyType,
required String encryptionKey,
required String message,
required Timestamp? expiration}) async {
final pool = DHTRecordPool.instance;
// Generate writer keypair to share with new contact
final cs = await pool.veilid.bestCryptoSystem();
final contactRequestWriter = await cs.generateKeyPair();
final conversationWriter = _activeAccountInfo.conversationWriter;
// Encrypt the writer secret with the encryption key
final encryptedSecret = await encryptionKeyType.encryptSecretToBytes(
secret: contactRequestWriter.secret,
cryptoKind: cs.kind(),
encryptionKey: encryptionKey,
);
// Create local conversation DHT record with the account record key as its
// parent.
// Do not set the encryption of this key yet as it will not yet be written
// to and it will be eventually encrypted with the DH of the contact's
// identity key
late final Uint8List signedContactInvitationBytes;
await (await pool.create(
parent: _activeAccountInfo.accountRecordKey,
schema: DHTSchema.smpl(oCnt: 0, members: [
DHTSchemaMember(mKey: conversationWriter.key, mCnt: 1)
])))
.deleteScope((localConversation) async {
// dont bother reopening localConversation with writer
// Make ContactRequestPrivate and encrypt with the writer secret
final crpriv = proto.ContactRequestPrivate()
..writerKey = contactRequestWriter.key.toProto()
..profile = _account.profile
..identityMasterRecordKey =
_activeAccountInfo.userLogin.accountMasterRecordKey.toProto()
..chatRecordKey = localConversation.key.toProto()
..expiration = expiration?.toInt64() ?? Int64.ZERO;
final crprivbytes = crpriv.writeToBuffer();
final encryptedContactRequestPrivate = await cs.encryptAeadWithNonce(
crprivbytes, contactRequestWriter.secret);
// Create ContactRequest and embed contactrequestprivate
final creq = proto.ContactRequest()
..encryptionKeyType = encryptionKeyType.toProto()
..private = encryptedContactRequestPrivate;
// Create DHT unicast inbox for ContactRequest
// Subkey 0 is the ContactRequest from the initiator
// Subkey 1 will contain the invitation response accept/reject eventually
await (await pool.create(
parent: _activeAccountInfo.accountRecordKey,
schema: DHTSchema.smpl(oCnt: 1, members: [
DHTSchemaMember(mCnt: 1, mKey: contactRequestWriter.key)
]),
crypto: const DHTRecordCryptoPublic()))
.deleteScope((contactRequestInbox) async {
// Store ContactRequest in owner subkey
await contactRequestInbox.eventualWriteProtobuf(creq);
// Create ContactInvitation and SignedContactInvitation
final cinv = proto.ContactInvitation()
..contactRequestInboxKey = contactRequestInbox.key.toProto()
..writerSecret = encryptedSecret;
final cinvbytes = cinv.writeToBuffer();
final scinv = proto.SignedContactInvitation()
..contactInvitation = cinvbytes
..identitySignature = (await cs.sign(
conversationWriter.key, conversationWriter.secret, cinvbytes))
.toProto();
signedContactInvitationBytes = scinv.writeToBuffer();
// Create ContactInvitationRecord
final cinvrec = proto.ContactInvitationRecord()
..contactRequestInbox =
contactRequestInbox.ownedDHTRecordPointer.toProto()
..writerKey = contactRequestWriter.key.toProto()
..writerSecret = contactRequestWriter.secret.toProto()
..localConversationRecordKey = localConversation.key.toProto()
..expiration = expiration?.toInt64() ?? Int64.ZERO
..invitation = signedContactInvitationBytes
..message = message;
// Add ContactInvitationRecord to account's list
// if this fails, don't keep retrying, user can try again later
await operateWrite((writer) async {
if (await writer.tryAddItem(cinvrec.writeToBuffer()) == false) {
throw Exception('Failed to add contact invitation record');
}
});
});
});
return signedContactInvitationBytes;
}
Future<void> deleteInvitation(
{required bool accepted,
required TypedKey contactRequestInboxRecordKey}) async {
final pool = DHTRecordPool.instance;
final accountRecordKey =
_activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey;
// Remove ContactInvitationRecord from account's list
final (deletedItem, success) = await operateWrite((writer) async {
for (var i = 0; i < writer.length; i++) {
final item = await writer.getItemProtobuf(
proto.ContactInvitationRecord.fromBuffer, i);
if (item == null) {
throw Exception('Failed to get contact invitation record');
}
if (item.contactRequestInbox.recordKey.toVeilid() ==
contactRequestInboxRecordKey) {
if (await writer.tryRemoveItem(i) != null) {
return item;
}
return null;
}
}
return null;
});
if (success && deletedItem != null) {
// Delete the contact request inbox
final contactRequestInbox = deletedItem.contactRequestInbox.toVeilid();
await (await pool.openOwned(contactRequestInbox,
parent: accountRecordKey))
.scope((contactRequestInbox) async {
// Wipe out old invitation so it shows up as invalid
await contactRequestInbox.tryWriteBytes(Uint8List(0));
});
try {
await pool.delete(contactRequestInbox.recordKey);
} on Exception catch (e) {
log.debug('error removing contact request inbox: $e', e);
}
if (!accepted) {
try {
await pool.delete(deletedItem.localConversationRecordKey.toVeilid());
} on Exception catch (e) {
log.debug('error removing local conversation record: $e', e);
}
}
}
}
Future<ValidContactInvitation?> validateInvitation(
{required Uint8List inviteData,
required GetEncryptionKeyCallback getEncryptionKeyCallback}) async {
final pool = DHTRecordPool.instance;
// Get contact request inbox from invitation
final signedContactInvitation =
proto.SignedContactInvitation.fromBuffer(inviteData);
final contactInvitationBytes =
Uint8List.fromList(signedContactInvitation.contactInvitation);
final contactInvitation =
proto.ContactInvitation.fromBuffer(contactInvitationBytes);
final contactRequestInboxKey =
contactInvitation.contactRequestInboxKey.toVeilid();
ValidContactInvitation? out;
final cs = await pool.veilid.getCryptoSystem(contactRequestInboxKey.kind);
// Compare the invitation's contact request
// inbox with our list of extant invitations
// If we're chatting to ourselves,
// we are validating an invitation we have created
final isSelf = state.state.data!.value.indexWhere((cir) =>
cir.contactRequestInbox.recordKey.toVeilid() ==
contactRequestInboxKey) !=
-1;
await (await pool.openRead(contactRequestInboxKey,
parent: _activeAccountInfo.accountRecordKey))
.maybeDeleteScope(!isSelf, (contactRequestInbox) async {
//
final contactRequest = await contactRequestInbox
.getProtobuf(proto.ContactRequest.fromBuffer);
// Decrypt contact request private
final encryptionKeyType =
EncryptionKeyType.fromProto(contactRequest!.encryptionKeyType);
late final SharedSecret? writerSecret;
try {
writerSecret = await getEncryptionKeyCallback(cs, encryptionKeyType,
Uint8List.fromList(contactInvitation.writerSecret));
} on Exception catch (_) {
throw ContactInviteInvalidKeyException(encryptionKeyType);
}
if (writerSecret == null) {
return null;
}
final contactRequestPrivateBytes = await cs.decryptAeadWithNonce(
Uint8List.fromList(contactRequest.private), writerSecret);
final contactRequestPrivate =
proto.ContactRequestPrivate.fromBuffer(contactRequestPrivateBytes);
final contactIdentityMasterRecordKey =
contactRequestPrivate.identityMasterRecordKey.toVeilid();
// Fetch the account master
final contactIdentityMaster = await openIdentityMaster(
identityMasterRecordKey: contactIdentityMasterRecordKey);
// Verify
final signature = signedContactInvitation.identitySignature.toVeilid();
await cs.verify(contactIdentityMaster.identityPublicKey,
contactInvitationBytes, signature);
final writer = KeyPair(
key: contactRequestPrivate.writerKey.toVeilid(),
secret: writerSecret);
out = ValidContactInvitation(
activeAccountInfo: _activeAccountInfo,
account: _account,
contactRequestInboxKey: contactRequestInboxKey,
contactRequestPrivate: contactRequestPrivate,
contactIdentityMaster: contactIdentityMaster,
writer: writer);
});
return out;
}
//
final ActiveAccountInfo _activeAccountInfo;
final proto.Account _account;
}

View File

@ -0,0 +1,43 @@
import 'package:veilid_support/veilid_support.dart';
import '../../account_manager/account_manager.dart';
import '../../proto/proto.dart' as proto;
// Watch subkey #1 of the ContactRequest record for accept/reject
class ContactRequestInboxCubit
extends DefaultDHTRecordCubit<proto.SignedContactResponse> {
ContactRequestInboxCubit(
{required this.activeAccountInfo, required this.contactInvitationRecord})
: super(
open: () => _open(
activeAccountInfo: activeAccountInfo,
contactInvitationRecord: contactInvitationRecord),
decodeState: proto.SignedContactResponse.fromBuffer);
// ContactRequestInboxCubit.value(
// {required super.record,
// required this.activeAccountInfo,
// required this.contactInvitationRecord})
// : super.value(decodeState: proto.SignedContactResponse.fromBuffer);
static Future<DHTRecord> _open(
{required ActiveAccountInfo activeAccountInfo,
required proto.ContactInvitationRecord contactInvitationRecord}) async {
final pool = DHTRecordPool.instance;
final accountRecordKey =
activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey;
final writerKey = contactInvitationRecord.writerKey.toVeilid();
final writerSecret = contactInvitationRecord.writerSecret.toVeilid();
final recordKey =
contactInvitationRecord.contactRequestInbox.recordKey.toVeilid();
final writer = TypedKeyPair(
kind: recordKey.kind, key: writerKey, secret: writerSecret);
return pool.openRead(recordKey,
crypto: await DHTRecordCryptoPrivate.fromTypedKeyPair(writer),
parent: accountRecordKey,
defaultSubkey: 1);
}
final ActiveAccountInfo activeAccountInfo;
final proto.ContactInvitationRecord contactInvitationRecord;
}

View File

@ -0,0 +1,5 @@
export 'contact_invitation_list_cubit.dart';
export 'contact_request_inbox_cubit.dart';
export 'invitation_generator_cubit.dart';
export 'waiting_invitation_cubit.dart';
export 'waiting_invitations_bloc_map_cubit.dart';

View File

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

View File

@ -0,0 +1,111 @@
import 'dart:typed_data';
import 'package:async_tools/async_tools.dart';
import 'package:bloc_tools/bloc_tools.dart';
import 'package:equatable/equatable.dart';
import 'package:meta/meta.dart';
import 'package:veilid_support/veilid_support.dart';
import '../../account_manager/account_manager.dart';
import '../../contacts/contacts.dart';
import '../../proto/proto.dart' as proto;
import '../../tools/tools.dart';
import '../models/accepted_contact.dart';
import 'contact_request_inbox_cubit.dart';
@immutable
class InvitationStatus extends Equatable {
const InvitationStatus({required this.acceptedContact});
final AcceptedContact? acceptedContact;
@override
List<Object?> get props => [acceptedContact];
}
class WaitingInvitationCubit extends AsyncTransformerCubit<InvitationStatus,
proto.SignedContactResponse> {
WaitingInvitationCubit(ContactRequestInboxCubit super.input,
{required ActiveAccountInfo activeAccountInfo,
required proto.Account account,
required proto.ContactInvitationRecord contactInvitationRecord})
: super(
transform: (signedContactResponse) => _transform(
signedContactResponse,
activeAccountInfo: activeAccountInfo,
account: account,
contactInvitationRecord: contactInvitationRecord));
static Future<AsyncValue<InvitationStatus>> _transform(
proto.SignedContactResponse signedContactResponse,
{required ActiveAccountInfo activeAccountInfo,
required proto.Account account,
required proto.ContactInvitationRecord contactInvitationRecord}) async {
final pool = DHTRecordPool.instance;
final contactResponseBytes =
Uint8List.fromList(signedContactResponse.contactResponse);
final contactResponse =
proto.ContactResponse.fromBuffer(contactResponseBytes);
final contactIdentityMasterRecordKey =
contactResponse.identityMasterRecordKey.toVeilid();
final cs =
await pool.veilid.getCryptoSystem(contactIdentityMasterRecordKey.kind);
// Fetch the remote contact's account master
final contactIdentityMaster = await openIdentityMaster(
identityMasterRecordKey: contactIdentityMasterRecordKey);
// Verify
final signature = signedContactResponse.identitySignature.toVeilid();
await cs.verify(contactIdentityMaster.identityPublicKey,
contactResponseBytes, signature);
// Check for rejection
if (!contactResponse.accept) {
// Rejection
return const AsyncValue.data(InvitationStatus(acceptedContact: null));
}
// Pull profile from remote conversation key
final remoteConversationRecordKey =
contactResponse.remoteConversationRecordKey.toVeilid();
final conversation = ConversationCubit(
activeAccountInfo: activeAccountInfo,
remoteIdentityPublicKey: contactIdentityMaster.identityPublicTypedKey(),
remoteConversationRecordKey: remoteConversationRecordKey);
// wait for remote conversation for up to 20 seconds
proto.Conversation? remoteConversation;
var retryCount = 20;
do {
await conversation.refresh();
remoteConversation = conversation.state.data?.value.remoteConversation;
if (remoteConversation != null) {
break;
}
log.info('Remote conversation could not be read. Waiting...');
await Future<void>.delayed(const Duration(seconds: 1));
retryCount--;
} while (retryCount > 0);
if (remoteConversation == null) {
return AsyncValue.error('Invitation accept timed out.');
}
// Complete the local conversation now that we have the remote profile
final remoteProfile = remoteConversation.profile;
final localConversationRecordKey =
contactInvitationRecord.localConversationRecordKey.toVeilid();
return conversation.initLocalConversation(
existingConversationRecordKey: localConversationRecordKey,
profile: account.profile,
// ignore: prefer_expression_function_bodies
callback: (localConversation) async {
return AsyncValue.data(InvitationStatus(
acceptedContact: AcceptedContact(
remoteProfile: remoteProfile,
remoteIdentity: contactIdentityMaster,
remoteConversationRecordKey: remoteConversationRecordKey,
localConversationRecordKey: localConversationRecordKey)));
});
}
}

View File

@ -0,0 +1,62 @@
import 'package:async_tools/async_tools.dart';
import 'package:bloc_tools/bloc_tools.dart';
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:veilid_support/veilid_support.dart';
import '../../account_manager/account_manager.dart';
import '../../proto/proto.dart' as proto;
import 'cubits.dart';
typedef WaitingInvitationsBlocMapState
= BlocMapState<TypedKey, AsyncValue<InvitationStatus>>;
// Map of contactRequestInboxRecordKey to WaitingInvitationCubit
// Wraps a contact invitation cubit to watch for accept/reject
// Automatically follows the state of a ContactInvitationListCubit.
class WaitingInvitationsBlocMapCubit extends BlocMapCubit<TypedKey,
AsyncValue<InvitationStatus>, WaitingInvitationCubit>
with
StateFollower<
BlocBusyState<AsyncValue<IList<proto.ContactInvitationRecord>>>,
TypedKey,
proto.ContactInvitationRecord> {
WaitingInvitationsBlocMapCubit(
{required this.activeAccountInfo, required this.account});
Future<void> _addWaitingInvitation(
{required proto.ContactInvitationRecord
contactInvitationRecord}) async =>
add(() => MapEntry(
contactInvitationRecord.contactRequestInbox.recordKey.toVeilid(),
WaitingInvitationCubit(
ContactRequestInboxCubit(
activeAccountInfo: activeAccountInfo,
contactInvitationRecord: contactInvitationRecord),
activeAccountInfo: activeAccountInfo,
account: account,
contactInvitationRecord: contactInvitationRecord)));
/// StateFollower /////////////////////////
@override
IMap<TypedKey, proto.ContactInvitationRecord> getStateMap(
BlocBusyState<AsyncValue<IList<proto.ContactInvitationRecord>>> state) {
final stateValue = state.state.data?.value;
if (stateValue == null) {
return IMap();
}
return IMap.fromIterable(stateValue,
keyMapper: (e) => e.contactRequestInbox.recordKey.toVeilid(),
valueMapper: (e) => e);
}
@override
Future<void> removeFromState(TypedKey key) => remove(key);
@override
Future<void> updateState(TypedKey key, proto.ContactInvitationRecord value) =>
_addWaitingInvitation(contactInvitationRecord: value);
////
final ActiveAccountInfo activeAccountInfo;
final proto.Account account;
}

View File

@ -0,0 +1,28 @@
import 'package:equatable/equatable.dart';
import 'package:meta/meta.dart';
import 'package:veilid_support/veilid_support.dart';
import '../../proto/proto.dart' as proto;
@immutable
class AcceptedContact extends Equatable {
const AcceptedContact({
required this.remoteProfile,
required this.remoteIdentity,
required this.remoteConversationRecordKey,
required this.localConversationRecordKey,
});
final proto.Profile remoteProfile;
final IdentityMaster remoteIdentity;
final TypedKey remoteConversationRecordKey;
final TypedKey localConversationRecordKey;
@override
List<Object?> get props => [
remoteProfile,
remoteIdentity,
remoteConversationRecordKey,
localConversationRecordKey
];
}

View File

@ -0,0 +1,2 @@
export 'accepted_contact.dart';
export 'valid_contact_invitation.dart';

View File

@ -0,0 +1,146 @@
import 'package:meta/meta.dart';
import 'package:veilid_support/veilid_support.dart';
import '../../account_manager/account_manager.dart';
import '../../contacts/contacts.dart';
import '../../proto/proto.dart' as proto;
import '../../tools/tools.dart';
import 'models.dart';
//////////////////////////////////////////////////
///
class ValidContactInvitation {
@internal
ValidContactInvitation(
{required ActiveAccountInfo activeAccountInfo,
required proto.Account account,
required TypedKey contactRequestInboxKey,
required proto.ContactRequestPrivate contactRequestPrivate,
required IdentityMaster contactIdentityMaster,
required KeyPair writer})
: _activeAccountInfo = activeAccountInfo,
_account = account,
_contactRequestInboxKey = contactRequestInboxKey,
_contactRequestPrivate = contactRequestPrivate,
_contactIdentityMaster = contactIdentityMaster,
_writer = writer;
proto.Profile get remoteProfile => _contactRequestPrivate.profile;
Future<AcceptedContact?> accept() async {
final pool = DHTRecordPool.instance;
try {
// Ensure we don't delete this if we're trying to chat to self
// The initiating side will delete the records in deleteInvitation()
final isSelf = _contactIdentityMaster.identityPublicKey ==
_activeAccountInfo.localAccount.identityMaster.identityPublicKey;
final accountRecordKey = _activeAccountInfo.accountRecordKey;
return (await pool.openWrite(_contactRequestInboxKey, _writer,
parent: accountRecordKey))
// ignore: prefer_expression_function_bodies
.maybeDeleteScope(!isSelf, (contactRequestInbox) async {
// Create local conversation key for this
// contact and send via contact response
final conversation = ConversationCubit(
activeAccountInfo: _activeAccountInfo,
remoteIdentityPublicKey:
_contactIdentityMaster.identityPublicTypedKey());
return conversation.initLocalConversation(
profile: _account.profile,
callback: (localConversation) async {
final contactResponse = proto.ContactResponse()
..accept = true
..remoteConversationRecordKey = localConversation.key.toProto()
..identityMasterRecordKey = _activeAccountInfo
.localAccount.identityMaster.masterRecordKey
.toProto();
final contactResponseBytes = contactResponse.writeToBuffer();
final cs = await pool.veilid
.getCryptoSystem(_contactRequestInboxKey.kind);
final identitySignature = await cs.sign(
_activeAccountInfo.conversationWriter.key,
_activeAccountInfo.conversationWriter.secret,
contactResponseBytes);
final signedContactResponse = proto.SignedContactResponse()
..contactResponse = contactResponseBytes
..identitySignature = identitySignature.toProto();
// Write the acceptance to the inbox
if (await contactRequestInbox.tryWriteProtobuf(
proto.SignedContactResponse.fromBuffer,
signedContactResponse,
subkey: 1) !=
null) {
throw Exception('failed to accept contact invitation');
}
return AcceptedContact(
remoteProfile: _contactRequestPrivate.profile,
remoteIdentity: _contactIdentityMaster,
remoteConversationRecordKey:
_contactRequestPrivate.chatRecordKey.toVeilid(),
localConversationRecordKey: localConversation.key,
);
});
});
} on Exception catch (e) {
log.debug('exception: $e', e);
return null;
}
}
Future<bool> reject() async {
final pool = DHTRecordPool.instance;
// Ensure we don't delete this if we're trying to chat to self
final isSelf = _contactIdentityMaster.identityPublicKey ==
_activeAccountInfo.localAccount.identityMaster.identityPublicKey;
final accountRecordKey =
_activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey;
return (await pool.openWrite(_contactRequestInboxKey, _writer,
parent: accountRecordKey))
.maybeDeleteScope(!isSelf, (contactRequestInbox) async {
final cs =
await pool.veilid.getCryptoSystem(_contactRequestInboxKey.kind);
final contactResponse = proto.ContactResponse()
..accept = false
..identityMasterRecordKey = _activeAccountInfo
.localAccount.identityMaster.masterRecordKey
.toProto();
final contactResponseBytes = contactResponse.writeToBuffer();
final identitySignature = await cs.sign(
_activeAccountInfo.conversationWriter.key,
_activeAccountInfo.conversationWriter.secret,
contactResponseBytes);
final signedContactResponse = proto.SignedContactResponse()
..contactResponse = contactResponseBytes
..identitySignature = identitySignature.toProto();
// Write the rejection to the inbox
if (await contactRequestInbox.tryWriteProtobuf(
proto.SignedContactResponse.fromBuffer, signedContactResponse,
subkey: 1) !=
null) {
log.error('failed to reject contact invitation');
return false;
}
return true;
});
}
//
final ActiveAccountInfo _activeAccountInfo;
final proto.Account _account;
final TypedKey _contactRequestInboxKey;
final IdentityMaster _contactIdentityMaster;
final KeyPair _writer;
final proto.ContactRequestPrivate _contactRequestPrivate;
}

View File

@ -0,0 +1,132 @@
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:provider/provider.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:veilid_support/veilid_support.dart';
import '../../tools/tools.dart';
import '../contact_invitation.dart';
class ContactInvitationDisplayDialog extends StatefulWidget {
const ContactInvitationDisplayDialog({
required this.message,
super.key,
});
final String message;
@override
State<ContactInvitationDisplayDialog> createState() =>
_ContactInvitationDisplayDialogState();
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(StringProperty('message', message));
}
}
class _ContactInvitationDisplayDialogState
extends State<ContactInvitationDisplayDialog> {
final focusNode = FocusNode();
final formKey = GlobalKey<FormState>();
@override
void initState() {
super.initState();
}
@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 =
context.watch<InvitationGeneratorCubit>().state;
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,
data: (data) => 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: errorPage)));
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties
..add(DiagnosticsProperty<FocusNode>('focusNode', focusNode))
..add(DiagnosticsProperty<GlobalKey<FormState>>('formKey', formKey));
}
}

View File

@ -1,30 +1,33 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:flutter_translate/flutter_translate.dart';
import '../proto/proto.dart' as proto;
import '../providers/account.dart';
import '../providers/contact_invite.dart';
import '../tools/tools.dart';
import 'contact_invitation_display.dart';
import '../../proto/proto.dart' as proto;
import '../../theme/theme.dart';
import '../contact_invitation.dart';
class ContactInvitationItemWidget extends ConsumerWidget {
class ContactInvitationItemWidget extends StatelessWidget {
const ContactInvitationItemWidget(
{required this.contactInvitationRecord, super.key});
{required this.contactInvitationRecord,
required this.disabled,
super.key});
final proto.ContactInvitationRecord contactInvitationRecord;
final bool disabled;
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<proto.ContactInvitationRecord>(
'contactInvitationRecord', contactInvitationRecord));
properties
..add(DiagnosticsProperty<proto.ContactInvitationRecord>(
'contactInvitationRecord', contactInvitationRecord))
..add(DiagnosticsProperty<bool>('disabled', disabled));
}
@override
// ignore: prefer_expression_function_bodies
Widget build(BuildContext context, WidgetRef ref) {
Widget build(BuildContext context) {
final theme = Theme.of(context);
//final textTheme = theme.textTheme;
final scale = theme.extension<ScaleScheme>()!;
@ -50,16 +53,17 @@ class ContactInvitationItemWidget extends ConsumerWidget {
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(
onPressed: disabled
? null
: (context) async {
final contactInvitationListCubit =
context.read<ContactInvitationListCubit>();
await contactInvitationListCubit.deleteInvitation(
accepted: false,
activeAccountInfo: activeAccountInfo,
contactInvitationRecord: contactInvitationRecord);
ref.invalidate(fetchContactInvitationRecordsProvider);
}
contactRequestInboxRecordKey:
contactInvitationRecord
.contactRequestInbox.recordKey
.toVeilid());
},
backgroundColor: scale.tertiaryScale.background,
foregroundColor: scale.tertiaryScale.text,
@ -95,23 +99,21 @@ class ContactInvitationItemWidget extends ConsumerWidget {
// 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
onTap: disabled
? null
: () async {
if (!context.mounted) {
return;
}
await showDialog<void>(
context: context,
builder: (context) => ContactInvitationDisplayDialog(
name: activeAccountInfo.localAccount.name,
builder: (context) => BlocProvider(
create: (context) => InvitationGeneratorCubit
.value(Uint8List.fromList(
contactInvitationRecord.invitation)),
child: ContactInvitationDisplayDialog(
message: contactInvitationRecord.message,
generator: Uint8List.fromList(
contactInvitationRecord.invitation),
));
}
)));
},
title: Text(
contactInvitationRecord.message.isEmpty

View File

@ -2,19 +2,20 @@ 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 'package:flutter_riverpod/flutter_riverpod.dart';
import '../proto/proto.dart' as proto;
import '../tools/tools.dart';
import '../../proto/proto.dart' as proto;
import '../../theme/theme.dart';
import 'contact_invitation_item_widget.dart';
class ContactInvitationListWidget extends ConsumerStatefulWidget {
class ContactInvitationListWidget extends StatefulWidget {
const ContactInvitationListWidget({
required this.contactInvitationRecordList,
required this.disabled,
super.key,
});
final IList<proto.ContactInvitationRecord> contactInvitationRecordList;
final bool disabled;
@override
ContactInvitationListWidgetState createState() =>
@ -22,13 +23,15 @@ class ContactInvitationListWidget extends ConsumerStatefulWidget {
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(IterableProperty<proto.ContactInvitationRecord>(
'contactInvitationRecordList', contactInvitationRecordList));
properties
..add(IterableProperty<proto.ContactInvitationRecord>(
'contactInvitationRecordList', contactInvitationRecordList))
..add(DiagnosticsProperty<bool>('disabled', disabled));
}
}
class ContactInvitationListWidgetState
extends ConsumerState<ContactInvitationListWidget> {
extends State<ContactInvitationListWidget> {
final ScrollController _scrollController = ScrollController();
@override
@ -64,6 +67,7 @@ class ContactInvitationListWidgetState
return ContactInvitationItemWidget(
contactInvitationRecord:
widget.contactInvitationRecordList[index],
disabled: widget.disabled,
key: ObjectKey(widget.contactInvitationRecordList[index]))
.paddingLTRB(4, 2, 4, 2);
},

View File

@ -3,21 +3,18 @@ import 'dart:async';
import 'package:awesome_extensions/awesome_extensions.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_translate/flutter_translate.dart';
import '../entities/local_account.dart';
import '../providers/account.dart';
import '../providers/contact.dart';
import '../providers/contact_invite.dart';
import '../tools/tools.dart';
import 'enter_password.dart';
import 'enter_pin.dart';
import 'profile_widget.dart';
import '../../account_manager/account_manager.dart';
import '../../contacts/contacts.dart';
import '../../tools/tools.dart';
import '../contact_invitation.dart';
class InviteDialog extends ConsumerStatefulWidget {
class InviteDialog extends StatefulWidget {
const InviteDialog(
{required this.onValidationCancelled,
{required this.modalContext,
required this.onValidationCancelled,
required this.onValidationSuccess,
required this.onValidationFailed,
required this.inviteControlIsValid,
@ -33,6 +30,7 @@ class InviteDialog extends ConsumerStatefulWidget {
InviteDialogState dialogState,
Future<void> Function({required Uint8List inviteData})
validateInviteData) buildInviteControl;
final BuildContext modalContext;
@override
InviteDialogState createState() => InviteDialogState();
@ -54,11 +52,12 @@ class InviteDialog extends ConsumerStatefulWidget {
InviteDialogState dialogState,
Future<void> Function({required Uint8List inviteData})
validateInviteData)>.has(
'buildInviteControl', buildInviteControl));
'buildInviteControl', buildInviteControl))
..add(DiagnosticsProperty<BuildContext>('modalContext', modalContext));
}
}
class InviteDialogState extends ConsumerState<InviteDialog> {
class InviteDialogState extends State<InviteDialog> {
ValidContactInvitation? _validInvitation;
bool _isValidating = false;
bool _isAccepting = false;
@ -73,22 +72,15 @@ class InviteDialogState extends ConsumerState<InviteDialog> {
Future<void> _onAccept() async {
final navigator = Navigator.of(context);
final activeAccountInfo = widget.modalContext.read<ActiveAccountInfo>();
final contactList = widget.modalContext.read<ContactListCubit>();
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);
final acceptedContact = await validInvitation.accept();
if (acceptedContact != null) {
// initiator when accept is received will create
// contact in the case of a 'note to self'
@ -96,9 +88,8 @@ class InviteDialogState extends ConsumerState<InviteDialog> {
activeAccountInfo.localAccount.identityMaster.identityPublicKey ==
acceptedContact.remoteIdentity.identityPublicKey;
if (!isSelf) {
await createContact(
activeAccountInfo: activeAccountInfo,
profile: acceptedContact.profile,
await contactList.createContact(
remoteProfile: acceptedContact.remoteProfile,
remoteIdentity: acceptedContact.remoteIdentity,
remoteConversationRecordKey:
acceptedContact.remoteConversationRecordKey,
@ -106,9 +97,6 @@ class InviteDialogState extends ConsumerState<InviteDialog> {
acceptedContact.localConversationRecordKey,
);
}
ref
..invalidate(fetchContactInvitationRecordsProvider)
..invalidate(fetchContactListProvider);
} else {
if (context.mounted) {
showErrorToast(context, 'invite_dialog.failed_to_accept');
@ -127,17 +115,9 @@ class InviteDialogState extends ConsumerState<InviteDialog> {
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)) {
if (await validInvitation.reject()) {
// do nothing right now
} else {
if (context.mounted) {
@ -155,25 +135,15 @@ class InviteDialogState extends ConsumerState<InviteDialog> {
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);
final contactInvitationListCubit =
widget.modalContext.read<ContactInvitationListCubit>();
setState(() {
_isValidating = true;
_validInvitation = null;
});
final validatedContactInvitation = await validateContactInvitation(
activeAccountInfo: activeAccountInfo,
contactInvitationRecords: contactInvitationRecords,
final validatedContactInvitation =
await contactInvitationListCubit.validateInvitation(
inviteData: inviteData,
getEncryptionKeyCallback:
(cs, encryptionKeyType, encryptedSecret) async {
@ -210,10 +180,9 @@ class InviteDialogState extends ConsumerState<InviteDialog> {
}
encryptionKey = password;
}
return decryptSecretFromBytes(
return encryptionKeyType.decryptSecretFromBytes(
secretBytes: encryptedSecret,
cryptoKind: cs.kind(),
encryptionKeyType: encryptionKeyType,
encryptionKey: encryptionKey);
});
@ -276,7 +245,7 @@ class InviteDialogState extends ConsumerState<InviteDialog> {
return SizedBox(
height: 300,
width: 300,
child: buildProgressIndicator(context).toCenter())
child: buildProgressIndicator().toCenter())
.paddingAll(16);
}
return ConstrainedBox(
@ -292,7 +261,7 @@ class InviteDialogState extends ConsumerState<InviteDialog> {
Column(children: [
Text(translate('invite_dialog.validating'))
.paddingLTRB(0, 0, 0, 16),
buildProgressIndicator(context).paddingAll(16),
buildProgressIndicator().paddingAll(16),
]).toCenter(),
if (_validInvitation == null &&
!_isValidating &&
@ -307,11 +276,8 @@ class InviteDialogState extends ConsumerState<InviteDialog> {
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),
profile: _validInvitation!.remoteProfile))
.paddingLTRB(0, 0, 0, 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [

View File

@ -0,0 +1,66 @@
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 sheetContext, BuildContext context) {
final theme = Theme.of(sheetContext);
final textTheme = theme.textTheme;
final scale = theme.extension<ScaleScheme>()!;
return KeyboardListener(
focusNode: FocusNode(),
onKeyEvent: (ke) {
if (ke.logicalKey == LogicalKeyboardKey.escape) {
Navigator.pop(sheetContext);
}
},
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(sheetContext);
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(sheetContext);
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(sheetContext);
await PasteInviteDialog.show(context);
},
iconSize: 64,
icon: const Icon(Icons.paste),
color: scale.primaryScale.background),
Text(translate('accounts_menu.paste_invite'))
])
]).expanded()
])));
}

View File

@ -2,16 +2,17 @@ import 'dart:async';
import 'dart:typed_data';
import 'package:awesome_extensions/awesome_extensions.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_translate/flutter_translate.dart';
import 'package:veilid_support/veilid_support.dart';
import '../tools/tools.dart';
import '../veilid_support/veilid_support.dart';
import '../../theme/theme.dart';
import '../../tools/tools.dart';
import 'invite_dialog.dart';
class PasteInviteDialog extends ConsumerStatefulWidget {
const PasteInviteDialog({super.key});
class PasteInviteDialog extends StatefulWidget {
const PasteInviteDialog({required this.modalContext, super.key});
@override
PasteInviteDialogState createState() => PasteInviteDialogState();
@ -20,11 +21,20 @@ class PasteInviteDialog extends ConsumerStatefulWidget {
await showStyledDialog<void>(
context: context,
title: translate('paste_invite_dialog.title'),
child: const PasteInviteDialog());
child: PasteInviteDialog(modalContext: context));
}
final BuildContext modalContext;
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties
.add(DiagnosticsProperty<BuildContext>('modalContext', modalContext));
}
}
class PasteInviteDialogState extends ConsumerState<PasteInviteDialog> {
class PasteInviteDialogState extends State<PasteInviteDialog> {
final _pasteTextController = TextEditingController();
@override
@ -122,6 +132,7 @@ class PasteInviteDialogState extends ConsumerState<PasteInviteDialog> {
// ignore: prefer_expression_function_bodies
Widget build(BuildContext context) {
return InviteDialog(
modalContext: widget.modalContext,
onValidationCancelled: onValidationCancelled,
onValidationSuccess: onValidationSuccess,
onValidationFailed: onValidationFailed,

View File

@ -6,14 +6,14 @@ 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 '../tools/tools.dart';
import '../../theme/theme.dart';
import '../../tools/tools.dart';
import 'invite_dialog.dart';
class BarcodeOverlay extends CustomPainter {
@ -31,9 +31,6 @@ class BarcodeOverlay extends CustomPainter {
@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;
@ -50,15 +47,14 @@ class BarcodeOverlay extends CustomPainter {
horizontalPadding = 0;
}
final ratioWidth =
(Platform.isIOS ? capture.width! : arguments.size.width) /
final ratioWidth = (Platform.isIOS ? capture.width : arguments.size.width) /
adjustedSize.destination.width;
final ratioHeight =
(Platform.isIOS ? capture.height! : arguments.size.height) /
(Platform.isIOS ? capture.height : arguments.size.height) /
adjustedSize.destination.height;
final adjustedOffset = <Offset>[];
for (final offset in barcode.corners!) {
for (final offset in barcode.corners) {
adjustedOffset.add(
Offset(
offset.dx / ratioWidth + horizontalPadding,
@ -107,8 +103,8 @@ class ScannerOverlay extends CustomPainter {
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
class ScanInviteDialog extends ConsumerStatefulWidget {
const ScanInviteDialog({super.key});
class ScanInviteDialog extends StatefulWidget {
const ScanInviteDialog({required this.modalContext, super.key});
@override
ScanInviteDialogState createState() => ScanInviteDialogState();
@ -117,11 +113,20 @@ class ScanInviteDialog extends ConsumerStatefulWidget {
await showStyledDialog<void>(
context: context,
title: translate('scan_invite_dialog.title'),
child: const ScanInviteDialog());
child: ScanInviteDialog(modalContext: context));
}
final BuildContext modalContext;
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties
.add(DiagnosticsProperty<BuildContext>('modalContext', modalContext));
}
}
class ScanInviteDialogState extends ConsumerState<ScanInviteDialog> {
class ScanInviteDialogState extends State<ScanInviteDialog> {
bool scanned = false;
@override
@ -384,6 +389,7 @@ class ScanInviteDialogState extends ConsumerState<ScanInviteDialog> {
// ignore: prefer_expression_function_bodies
Widget build(BuildContext context) {
return InviteDialog(
modalContext: widget.modalContext,
onValidationCancelled: onValidationCancelled,
onValidationSuccess: onValidationSuccess,
onValidationFailed: onValidationFailed,

View File

@ -5,20 +5,16 @@ 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_riverpod/flutter_riverpod.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_translate/flutter_translate.dart';
import 'package:veilid_support/veilid_support.dart';
import '../entities/local_account.dart';
import '../providers/account.dart';
import '../providers/contact_invite.dart';
import '../tools/tools.dart';
import '../veilid_support/veilid_support.dart';
import 'contact_invitation_display.dart';
import 'enter_password.dart';
import 'enter_pin.dart';
import '../../account_manager/account_manager.dart';
import '../../tools/tools.dart';
import '../contact_invitation.dart';
class SendInviteDialog extends ConsumerStatefulWidget {
const SendInviteDialog({super.key});
class SendInviteDialog extends StatefulWidget {
const SendInviteDialog({required this.modalContext, super.key});
@override
SendInviteDialogState createState() => SendInviteDialogState();
@ -27,11 +23,20 @@ class SendInviteDialog extends ConsumerStatefulWidget {
await showStyledDialog<void>(
context: context,
title: translate('send_invite_dialog.title'),
child: const SendInviteDialog());
child: SendInviteDialog(modalContext: context));
}
final BuildContext modalContext;
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties
.add(DiagnosticsProperty<BuildContext>('modalContext', modalContext));
}
}
class SendInviteDialogState extends ConsumerState<SendInviteDialog> {
class SendInviteDialogState extends State<SendInviteDialog> {
final _messageTextController = TextEditingController(
text: translate('send_invite_dialog.connect_with_me'));
@ -61,8 +66,7 @@ class SendInviteDialogState extends ConsumerState<SendInviteDialog> {
if (pin == null) {
return;
}
// ignore: use_build_context_synchronously
if (!context.mounted) {
if (!mounted) {
return;
}
final matchpin = await showDialog<String>(
@ -79,8 +83,7 @@ class SendInviteDialogState extends ConsumerState<SendInviteDialog> {
_encryptionKey = pin;
});
} else {
// ignore: use_build_context_synchronously
if (!context.mounted) {
if (!mounted) {
return;
}
showErrorToast(
@ -100,8 +103,7 @@ class SendInviteDialogState extends ConsumerState<SendInviteDialog> {
if (password == null) {
return;
}
// ignore: use_build_context_synchronously
if (!context.mounted) {
if (!mounted) {
return;
}
final matchpass = await showDialog<String>(
@ -118,8 +120,7 @@ class SendInviteDialogState extends ConsumerState<SendInviteDialog> {
_encryptionKey = password;
});
} else {
// ignore: use_build_context_synchronously
if (!context.mounted) {
if (!mounted) {
return;
}
showErrorToast(
@ -135,32 +136,23 @@ class SendInviteDialogState extends ConsumerState<SendInviteDialog> {
final navigator = Navigator.of(context);
// Start generation
final activeAccountInfo = await ref.read(fetchActiveAccountProvider.future);
if (activeAccountInfo == null) {
navigator.pop();
return;
}
final generator = createContactInvitation(
activeAccountInfo: activeAccountInfo,
final contactInvitationListCubit =
widget.modalContext.read<ContactInvitationListCubit>();
final generator = contactInvitationListCubit.createInvitation(
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,
builder: (context) => BlocProvider(
create: (context) => InvitationGeneratorCubit(generator),
child: ContactInvitationDisplayDialog(
message: _messageTextController.text,
generator: generator,
));
// if (ret == null) {
// return;
// }
ref.invalidate(fetchContactInvitationRecordsProvider);
)));
navigator.pop();
}

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';

View File

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

View File

@ -0,0 +1,101 @@
import 'dart:async';
import 'dart:convert';
import 'package:veilid_support/veilid_support.dart';
import '../../account_manager/account_manager.dart';
import '../../proto/proto.dart' as proto;
import '../../tools/tools.dart';
//////////////////////////////////////////////////
// Mutable state for per-account contacts
class ContactListCubit extends DHTShortArrayCubit<proto.Contact> {
ContactListCubit({
required ActiveAccountInfo activeAccountInfo,
required proto.Account account,
}) : super(
open: () => _open(activeAccountInfo, account),
decodeElement: proto.Contact.fromBuffer);
static Future<DHTShortArray> _open(
ActiveAccountInfo activeAccountInfo, proto.Account account) async {
final accountRecordKey =
activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey;
final contactListRecordKey = account.contactList.toVeilid();
final dhtRecord = await DHTShortArray.openOwned(contactListRecordKey,
parent: accountRecordKey);
return dhtRecord;
}
Future<void> createContact({
required proto.Profile remoteProfile,
required IdentityMaster remoteIdentity,
required TypedKey remoteConversationRecordKey,
required TypedKey localConversationRecordKey,
}) async {
// Create Contact
final contact = proto.Contact()
..editedProfile = remoteProfile
..remoteProfile = remoteProfile
..identityMasterJson = jsonEncode(remoteIdentity.toJson())
..identityPublicKey = TypedKey(
kind: remoteIdentity.identityRecordKey.kind,
value: remoteIdentity.identityPublicKey)
.toProto()
..remoteConversationRecordKey = remoteConversationRecordKey.toProto()
..localConversationRecordKey = localConversationRecordKey.toProto()
..showAvailability = false;
// Add Contact to account's list
// if this fails, don't keep retrying, user can try again later
await operateWrite((writer) async {
if (!await writer.tryAddItem(contact.writeToBuffer())) {
throw Exception('Failed to add contact record');
}
});
}
Future<void> deleteContact({required proto.Contact contact}) async {
final pool = DHTRecordPool.instance;
final localConversationKey = contact.localConversationRecordKey.toVeilid();
final remoteConversationKey =
contact.remoteConversationRecordKey.toVeilid();
// Remove Contact from account's list
final (deletedItem, success) = await operateWrite((writer) async {
for (var i = 0; i < writer.length; i++) {
final item = await writer.getItemProtobuf(proto.Contact.fromBuffer, i);
if (item == null) {
throw Exception('Failed to get contact');
}
if (item.remoteConversationRecordKey ==
contact.remoteConversationRecordKey) {
if (await writer.tryRemoveItem(i) != null) {
return item;
}
return null;
}
}
return null;
});
if (success && deletedItem != null) {
try {
await pool.delete(localConversationKey);
} on Exception catch (e) {
log.debug('error removing local conversation record key: $e', e);
}
try {
if (localConversationKey != remoteConversationKey) {
await pool.delete(remoteConversationKey);
}
} on Exception catch (e) {
log.debug('error removing remote conversation record key: $e', e);
}
}
}
}

View File

@ -0,0 +1,292 @@
// A Conversation is a type of Chat that is 1:1 between two Contacts only
// Each Contact in the ContactList has at most one Conversation between the
// remote contact and the local account
import 'dart:async';
import 'dart:convert';
import 'package:async_tools/async_tools.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:meta/meta.dart';
import 'package:veilid_support/veilid_support.dart';
import '../../account_manager/account_manager.dart';
import '../../proto/proto.dart' as proto;
@immutable
class ConversationState extends Equatable {
const ConversationState(
{required this.localConversation, required this.remoteConversation});
final proto.Conversation? localConversation;
final proto.Conversation? remoteConversation;
@override
List<Object?> get props => [localConversation, remoteConversation];
}
class ConversationCubit extends Cubit<AsyncValue<ConversationState>> {
ConversationCubit(
{required ActiveAccountInfo activeAccountInfo,
required TypedKey remoteIdentityPublicKey,
TypedKey? localConversationRecordKey,
TypedKey? remoteConversationRecordKey})
: _activeAccountInfo = activeAccountInfo,
_localConversationRecordKey = localConversationRecordKey,
_remoteIdentityPublicKey = remoteIdentityPublicKey,
_remoteConversationRecordKey = remoteConversationRecordKey,
_incrementalState = const ConversationState(
localConversation: null, remoteConversation: null),
super(const AsyncValue.loading()) {
if (_localConversationRecordKey != null) {
Future.delayed(Duration.zero, () async {
await _setLocalConversation(() async {
final accountRecordKey = _activeAccountInfo
.userLogin.accountRecordInfo.accountRecord.recordKey;
// Open local record key if it is specified
final pool = DHTRecordPool.instance;
final crypto = await _cachedConversationCrypto();
final writer = _activeAccountInfo.conversationWriter;
final record = await pool.openWrite(
_localConversationRecordKey!, writer,
parent: accountRecordKey, crypto: crypto);
return record;
});
});
}
if (_remoteConversationRecordKey != null) {
Future.delayed(Duration.zero, () async {
await _setRemoteConversation(() async {
final accountRecordKey = _activeAccountInfo
.userLogin.accountRecordInfo.accountRecord.recordKey;
// Open remote record key if it is specified
final pool = DHTRecordPool.instance;
final crypto = await _cachedConversationCrypto();
final record = await pool.openRead(_remoteConversationRecordKey,
parent: accountRecordKey, crypto: crypto);
return record;
});
});
}
}
@override
Future<void> close() async {
await _localSubscription?.cancel();
await _remoteSubscription?.cancel();
await _localConversationCubit?.close();
await _remoteConversationCubit?.close();
await super.close();
}
void updateLocalConversationState(AsyncValue<proto.Conversation> avconv) {
final newState = avconv.when(
data: (conv) {
_incrementalState = ConversationState(
localConversation: conv,
remoteConversation: _incrementalState.remoteConversation);
// return loading still if state isn't complete
if ((_localConversationRecordKey != null &&
_incrementalState.localConversation == null) ||
(_remoteConversationRecordKey != null &&
_incrementalState.remoteConversation == null)) {
return const AsyncValue<ConversationState>.loading();
}
// state is complete, all required keys are open
return AsyncValue.data(_incrementalState);
},
loading: AsyncValue<ConversationState>.loading,
error: AsyncValue<ConversationState>.error,
);
emit(newState);
}
void updateRemoteConversationState(AsyncValue<proto.Conversation> avconv) {
final newState = avconv.when(
data: (conv) {
_incrementalState = ConversationState(
localConversation: _incrementalState.localConversation,
remoteConversation: conv);
// return loading still if state isn't complete
if ((_localConversationRecordKey != null &&
_incrementalState.localConversation == null) ||
(_remoteConversationRecordKey != null &&
_incrementalState.remoteConversation == null)) {
return const AsyncValue<ConversationState>.loading();
}
// state is complete, all required keys are open
return AsyncValue.data(_incrementalState);
},
loading: AsyncValue<ConversationState>.loading,
error: AsyncValue<ConversationState>.error,
);
emit(newState);
}
// Open local converation key
Future<void> _setLocalConversation(Future<DHTRecord> Function() open) async {
assert(_localConversationCubit == null,
'shoud not set local conversation twice');
_localConversationCubit = DefaultDHTRecordCubit(
open: open, decodeState: proto.Conversation.fromBuffer);
_localSubscription =
_localConversationCubit!.stream.listen(updateLocalConversationState);
}
// Open remote converation key
Future<void> _setRemoteConversation(Future<DHTRecord> Function() open) async {
assert(_remoteConversationCubit == null,
'shoud not set remote conversation twice');
_remoteConversationCubit = DefaultDHTRecordCubit(
open: open, decodeState: proto.Conversation.fromBuffer);
_remoteSubscription =
_remoteConversationCubit!.stream.listen(updateRemoteConversationState);
}
// Initialize a local conversation
// If we were the initiator of the conversation there may be an
// incomplete 'existingConversationRecord' that we need to fill
// in now that we have the remote identity key
// The ConversationCubit must not already have a local conversation
// The callback allows for more initialization to occur and for
// cleanup to delete records upon failure of the callback
Future<T> initLocalConversation<T>(
{required proto.Profile profile,
required FutureOr<T> Function(DHTRecord) callback,
TypedKey? existingConversationRecordKey}) async {
assert(_localConversationRecordKey == null,
'must not have a local conversation yet');
final pool = DHTRecordPool.instance;
final accountRecordKey =
_activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey;
final crypto = await _cachedConversationCrypto();
final writer = _activeAccountInfo.conversationWriter;
// Open with SMPL scheme for identity writer
late final DHTRecord localConversationRecord;
if (existingConversationRecordKey != null) {
localConversationRecord = await pool.openWrite(
existingConversationRecordKey, writer,
parent: accountRecordKey, crypto: crypto);
} else {
final localConversationRecordCreate = await pool.create(
parent: accountRecordKey,
crypto: crypto,
schema: DHTSchema.smpl(
oCnt: 0, members: [DHTSchemaMember(mKey: writer.key, mCnt: 1)]));
await localConversationRecordCreate.close();
localConversationRecord = await pool.openWrite(
localConversationRecordCreate.key, writer,
parent: accountRecordKey, crypto: crypto);
}
final out = localConversationRecord
// ignore: prefer_expression_function_bodies
.deleteScope((localConversation) async {
// Make messages log
return initLocalMessages(
activeAccountInfo: _activeAccountInfo,
remoteIdentityPublicKey: _remoteIdentityPublicKey,
localConversationKey: localConversation.key,
callback: (messages) async {
// Create initial local conversation key contents
final conversation = proto.Conversation()
..profile = profile
..identityMasterJson = jsonEncode(
_activeAccountInfo.localAccount.identityMaster.toJson())
..messages = messages.recordKey.toProto();
// Write initial conversation to record
final update = await localConversation.tryWriteProtobuf(
proto.Conversation.fromBuffer, conversation);
if (update != null) {
throw Exception('Failed to write local conversation');
}
final out = await callback(localConversation);
// Upon success emit the local conversation record to the state
updateLocalConversationState(AsyncValue.data(conversation));
return out;
});
});
// If success, save the new local conversation record key in this object
_localConversationRecordKey = localConversationRecord.key;
await _setLocalConversation(() async => localConversationRecord);
return out;
}
// Initialize local messages
Future<T> initLocalMessages<T>({
required ActiveAccountInfo activeAccountInfo,
required TypedKey remoteIdentityPublicKey,
required TypedKey localConversationKey,
required FutureOr<T> Function(DHTShortArray) callback,
}) async {
final crypto =
await activeAccountInfo.makeConversationCrypto(remoteIdentityPublicKey);
final writer = activeAccountInfo.conversationWriter;
return (await DHTShortArray.create(
parent: localConversationKey, crypto: crypto, smplWriter: writer))
.deleteScope((messages) async => await callback(messages));
}
// Force refresh of conversation keys
Future<void> refresh() async {
final lcc = _localConversationCubit;
final rcc = _remoteConversationCubit;
if (lcc != null) {
await lcc.refreshDefault();
}
if (rcc != null) {
await rcc.refreshDefault();
}
}
Future<proto.Conversation?> writeLocalConversation({
required proto.Conversation conversation,
}) async {
final update = await _localConversationCubit!.record
.tryWriteProtobuf(proto.Conversation.fromBuffer, conversation);
if (update != null) {
updateLocalConversationState(AsyncValue.data(conversation));
}
return update;
}
Future<DHTRecordCrypto> _cachedConversationCrypto() async {
var conversationCrypto = _conversationCrypto;
if (conversationCrypto != null) {
return conversationCrypto;
}
conversationCrypto = await _activeAccountInfo
.makeConversationCrypto(_remoteIdentityPublicKey);
_conversationCrypto = conversationCrypto;
return conversationCrypto;
}
final ActiveAccountInfo _activeAccountInfo;
final TypedKey _remoteIdentityPublicKey;
TypedKey? _localConversationRecordKey;
final TypedKey? _remoteConversationRecordKey;
DefaultDHTRecordCubit<proto.Conversation>? _localConversationCubit;
DefaultDHTRecordCubit<proto.Conversation>? _remoteConversationCubit;
StreamSubscription<AsyncValue<proto.Conversation>>? _localSubscription;
StreamSubscription<AsyncValue<proto.Conversation>>? _remoteSubscription;
ConversationState _incrementalState;
//
DHTRecordCrypto? _conversationCrypto;
}

View File

@ -0,0 +1,2 @@
export 'contact_list_cubit.dart';
export 'conversation_cubit.dart';

View File

@ -0,0 +1,109 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:flutter_translate/flutter_translate.dart';
import '../../chat_list/chat_list.dart';
import '../../layout/layout.dart';
import '../../proto/proto.dart' as proto;
import '../../theme/theme.dart';
import '../contacts.dart';
class ContactItemWidget extends StatelessWidget {
const ContactItemWidget(
{required this.contact, required this.disabled, super.key});
final proto.Contact contact;
final bool disabled;
@override
// ignore: prefer_expression_function_bodies
Widget build(
BuildContext context,
) {
final theme = Theme.of(context);
//final textTheme = theme.textTheme;
final scale = theme.extension<ScaleScheme>()!;
final remoteConversationKey =
contact.remoteConversationRecordKey.toVeilid();
return Container(
margin: const EdgeInsets.fromLTRB(0, 4, 0, 0),
clipBehavior: Clip.antiAlias,
decoration: ShapeDecoration(
color: scale.tertiaryScale.subtleBorder,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
)),
child: Slidable(
key: ObjectKey(contact),
endActionPane: ActionPane(
motion: const DrawerMotion(),
children: [
SlidableAction(
onPressed: disabled || context.watch<ChatListCubit>().isBusy
? null
: (context) async {
final contactListCubit =
context.read<ContactListCubit>();
final chatListCubit = context.read<ChatListCubit>();
// Remove any chats for this contact
await chatListCubit.deleteChat(
remoteConversationRecordKey:
remoteConversationKey);
// Delete the contact itself
await contactListCubit.deleteContact(
contact: contact);
},
backgroundColor: scale.tertiaryScale.background,
foregroundColor: scale.tertiaryScale.text,
icon: Icons.delete,
label: translate('button.delete'),
padding: const EdgeInsets.all(2)),
// SlidableAction(
// onPressed: (context) => (),
// backgroundColor: scale.secondaryScale.background,
// foregroundColor: scale.secondaryScale.text,
// icon: Icons.edit,
// label: 'Edit',
// ),
],
),
// The child of the Slidable is what the user sees when the
// component is not dragged.
child: ListTile(
onTap: disabled || context.watch<ChatListCubit>().isBusy
? null
: () async {
// Start a chat
final chatListCubit = context.read<ChatListCubit>();
await chatListCubit.getOrCreateChatSingleContact(
remoteConversationRecordKey: remoteConversationKey);
// Click over to chats
if (context.mounted) {
await MainPager.of(context)
?.pageController
.animateToPage(1,
duration: 250.ms, curve: Curves.easeInOut);
}
},
title: Text(contact.editedProfile.name),
subtitle: (contact.editedProfile.pronouns.isNotEmpty)
? Text(contact.editedProfile.pronouns)
: null,
iconColor: scale.tertiaryScale.background,
textColor: scale.tertiaryScale.text,
leading: const Icon(Icons.person))));
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<proto.Contact>('contact', contact));
}
}

View File

@ -2,27 +2,31 @@ 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 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_translate/flutter_translate.dart';
import 'package:searchable_listview/searchable_listview.dart';
import '../proto/proto.dart' as proto;
import '../tools/tools.dart';
import '../../proto/proto.dart' as proto;
import '../../theme/theme.dart';
import '../../tools/tools.dart';
import 'contact_item_widget.dart';
import 'empty_contact_list_widget.dart';
class ContactListWidget extends ConsumerWidget {
const ContactListWidget({required this.contactList, super.key});
class ContactListWidget extends StatelessWidget {
const ContactListWidget(
{required this.contactList, required this.disabled, super.key});
final IList<proto.Contact> contactList;
final bool disabled;
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(IterableProperty<proto.Contact>('contactList', contactList));
properties
..add(IterableProperty<proto.Contact>('contactList', contactList))
..add(DiagnosticsProperty<bool>('disabled', disabled));
}
@override
Widget build(BuildContext context, WidgetRef ref) {
Widget build(BuildContext context) {
final theme = Theme.of(context);
//final textTheme = theme.textTheme;
final scale = theme.extension<ScaleScheme>()!;
@ -35,9 +39,9 @@ class ContactListWidget extends ConsumerWidget {
child: (contactList.isEmpty)
? const EmptyContactListWidget()
: SearchableList<proto.Contact>(
autoFocusOnSearch: false,
initialList: contactList.toList(),
builder: (l, i, c) => ContactItemWidget(contact: c),
builder: (l, i, c) =>
ContactItemWidget(contact: c, disabled: disabled),
filter: (value) {
final lowerValue = value.toLowerCase();
return contactList

View File

@ -1,15 +1,16 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_translate/flutter_translate.dart';
import '../tools/tools.dart';
import '../../theme/theme.dart';
class EmptyContactListWidget extends ConsumerWidget {
class EmptyContactListWidget extends StatelessWidget {
const EmptyContactListWidget({super.key});
@override
// ignore: prefer_expression_function_bodies
Widget build(BuildContext context, WidgetRef ref) {
Widget build(
BuildContext context,
) {
final theme = Theme.of(context);
final textTheme = theme.textTheme;
final scale = theme.extension<ScaleScheme>()!;

View File

@ -0,0 +1,3 @@
export 'contact_item_widget.dart';
export 'contact_list_widget.dart';
export 'empty_contact_list_widget.dart';

View File

@ -1,3 +0,0 @@
export 'local_account.dart';
export 'preferences.dart';
export 'user_login.dart';

View File

@ -1,76 +0,0 @@
import 'dart:typed_data';
import 'package:change_case/change_case.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import '../proto/proto.dart' as proto;
import '../veilid_support/veilid_support.dart';
part 'local_account.freezed.dart';
part 'local_account.g.dart';
// Local account identitySecretKey is potentially encrypted with a key
// using the following mechanisms
// * None : no key, bytes are unencrypted
// * Pin : Code is a numeric pin (4-256 numeric digits) hashed with Argon2
// * Password: Code is a UTF-8 string that is hashed with Argon2
enum EncryptionKeyType {
none,
pin,
password;
factory EncryptionKeyType.fromJson(dynamic j) =>
EncryptionKeyType.values.byName((j as String).toCamelCase());
factory EncryptionKeyType.fromProto(proto.EncryptionKeyType p) {
// ignore: exhaustive_cases
switch (p) {
case proto.EncryptionKeyType.ENCRYPTION_KEY_TYPE_NONE:
return EncryptionKeyType.none;
case proto.EncryptionKeyType.ENCRYPTION_KEY_TYPE_PIN:
return EncryptionKeyType.pin;
case proto.EncryptionKeyType.ENCRYPTION_KEY_TYPE_PASSWORD:
return EncryptionKeyType.password;
}
throw StateError('unknown EncryptionKeyType enum value');
}
String toJson() => name.toPascalCase();
proto.EncryptionKeyType toProto() => switch (this) {
EncryptionKeyType.none =>
proto.EncryptionKeyType.ENCRYPTION_KEY_TYPE_NONE,
EncryptionKeyType.pin =>
proto.EncryptionKeyType.ENCRYPTION_KEY_TYPE_PIN,
EncryptionKeyType.password =>
proto.EncryptionKeyType.ENCRYPTION_KEY_TYPE_PASSWORD,
};
}
// Local Accounts are stored in a table locally and not backed by a DHT key
// and represents the accounts that have been added/imported
// on the current device.
// Stores a copy of the IdentityMaster associated with the account
// and the identitySecretKey optionally encrypted by an unlock code
// This is the root of the account information tree for VeilidChat
//
@freezed
class LocalAccount with _$LocalAccount {
const factory LocalAccount({
// The master key record for the account, containing the identityPublicKey
required IdentityMaster identityMaster,
// The encrypted identity secret that goes with
// the identityPublicKey with appended salt
@Uint8ListJsonConverter() required Uint8List identitySecretBytes,
// The kind of encryption input used on the account
required EncryptionKeyType encryptionKeyType,
// If account is not hidden, password can be retrieved via
required bool biometricsEnabled,
// Keep account hidden unless account password is entered
// (tries all hidden accounts with auth method (no biometrics))
required bool hiddenAccount,
// Display name for account until it is unlocked
required String name,
}) = _LocalAccount;
factory LocalAccount.fromJson(dynamic json) =>
_$LocalAccountFromJson(json as Map<String, dynamic>);
}

41
lib/init.dart Normal file
View File

@ -0,0 +1,41 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:veilid_support/veilid_support.dart';
import 'account_manager/account_manager.dart';
import 'app.dart';
import 'tools/tools.dart';
import 'veilid_processor/veilid_processor.dart';
final Completer<void> eventualInitialized = Completer<void>();
// Initialize Veilid
Future<void> initializeVeilid() async {
// Init Veilid
Veilid.instance.initializeVeilidCore(
getDefaultVeilidPlatformConfig(kIsWeb, VeilidChatApp.name));
// Veilid logging
initVeilidLog(kDebugMode);
// Startup Veilid
await ProcessorRepository.instance.startup();
// DHT Record Pool
await DHTRecordPool.init();
}
// Initialize repositories
Future<void> initializeRepositories() async {
await AccountRepository.instance.init();
}
Future<void> initializeVeilidChat() async {
log.info('Initializing Veilid');
await initializeVeilid();
log.info('Initializing Repositories');
await initializeRepositories();
eventualInitialized.complete();
}

View File

@ -0,0 +1,6 @@
export 'home_account_invalid.dart';
export 'home_account_locked.dart';
export 'home_account_missing.dart';
export 'home_account_ready/home_account_ready.dart';
export 'home_no_active.dart';
export 'home_shell.dart';

View File

@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
class HomeAccountInvalid extends StatefulWidget {
const HomeAccountInvalid({super.key});
@override
HomeAccountInvalidState createState() => HomeAccountInvalidState();
}
class HomeAccountInvalidState extends State<HomeAccountInvalid> {
@override
void initState() {
super.initState();
}
@override
void dispose() {
super.dispose();
}
@override
Widget build(BuildContext context) => const Text('Account invalid');
}
// xxx: delete invalid account
// Future.delayed(0.ms, () async {
// await showErrorModal(context, translate('home.invalid_account_title'),
// translate('home.invalid_account_text'));
// // Delete account
// await AccountRepository.instance.deleteLocalAccount(activeUserLogin);
// // Switch to no active user login
// await AccountRepository.instance.switchToAccount(null);
// });

View File

@ -0,0 +1,23 @@
import 'package:flutter/material.dart';
class HomeAccountLocked extends StatefulWidget {
const HomeAccountLocked({super.key});
@override
HomeAccountLockedState createState() => HomeAccountLockedState();
}
class HomeAccountLockedState extends State<HomeAccountLocked> {
@override
void initState() {
super.initState();
}
@override
void dispose() {
super.dispose();
}
@override
Widget build(BuildContext context) => const Text('Account locked');
}

View File

@ -0,0 +1,33 @@
import 'package:flutter/material.dart';
class HomeAccountMissing extends StatefulWidget {
const HomeAccountMissing({super.key});
@override
HomeAccountMissingState createState() => HomeAccountMissingState();
}
class HomeAccountMissingState extends State<HomeAccountMissing> {
@override
void initState() {
super.initState();
}
@override
void dispose() {
super.dispose();
}
@override
Widget build(BuildContext context) => const Text('Account missing');
}
// xxx click to delete missing account or add to postframecallback
// Future.delayed(0.ms, () async {
// await showErrorModal(context, translate('home.missing_account_title'),
// translate('home.missing_account_text'));
// // Delete account
// await AccountRepository.instance.deleteLocalAccount(activeUserLogin);
// // Switch to no active user login
// await AccountRepository.instance.switchToAccount(null);
// });

View File

@ -0,0 +1,3 @@
export 'home_account_ready_chat.dart';
export 'home_account_ready_main.dart';
export 'home_account_ready_shell.dart';

View File

@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../chat/chat.dart';
import '../../../tools/tools.dart';
class HomeAccountReadyChat extends StatefulWidget {
const HomeAccountReadyChat({super.key});
@override
HomeAccountReadyChatState createState() => HomeAccountReadyChatState();
}
class HomeAccountReadyChatState extends State<HomeAccountReadyChat> {
final _unfocusNode = FocusNode();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) async {
await changeWindowSetup(
TitleBarStyle.normal, OrientationCapability.normal);
});
}
@override
void dispose() {
_unfocusNode.dispose();
super.dispose();
}
Widget buildChatComponent(BuildContext context) {
final activeChatRemoteConversationKey =
context.watch<ActiveChatCubit>().state;
if (activeChatRemoteConversationKey == null) {
return const EmptyChatWidget();
}
return ChatComponent.builder(
remoteConversationRecordKey: activeChatRemoteConversationKey);
}
@override
Widget build(BuildContext context) => SafeArea(
child: GestureDetector(
onTap: () => FocusScope.of(context).requestFocus(_unfocusNode),
child: buildChatComponent(context),
));
}

View File

@ -0,0 +1,109 @@
import 'package:awesome_extensions/awesome_extensions.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_translate/flutter_translate.dart';
import 'package:go_router/go_router.dart';
import '../../../account_manager/account_manager.dart';
import '../../../chat/chat.dart';
import '../../../theme/theme.dart';
import '../../../tools/tools.dart';
import 'main_pager/main_pager.dart';
class HomeAccountReadyMain extends StatefulWidget {
const HomeAccountReadyMain({super.key});
@override
State<HomeAccountReadyMain> createState() => _HomeAccountReadyMainState();
}
class _HomeAccountReadyMainState extends State<HomeAccountReadyMain> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) async {
await changeWindowSetup(
TitleBarStyle.normal, OrientationCapability.normal);
});
}
Widget buildUserPanel() => Builder(builder: (context) {
final account = context.watch<AccountRecordCubit>().state;
final theme = Theme.of(context);
final scale = theme.extension<ScaleScheme>()!;
return Column(children: <Widget>[
Row(children: [
IconButton(
icon: const Icon(Icons.settings),
color: scale.secondaryScale.text,
constraints: const BoxConstraints.expand(height: 64, width: 64),
style: ButtonStyle(
backgroundColor:
MaterialStateProperty.all(scale.secondaryScale.border),
shape: MaterialStateProperty.all(
const RoundedRectangleBorder(
borderRadius:
BorderRadius.all(Radius.circular(16))))),
tooltip: translate('app_bar.settings_tooltip'),
onPressed: () async {
await GoRouterHelper(context).push('/settings');
}).paddingLTRB(0, 0, 8, 0),
asyncValueBuilder(account,
(_, account) => ProfileWidget(profile: account.profile))
.expanded(),
]).paddingAll(8),
const MainPager().expanded()
]);
});
Widget buildPhone(BuildContext context) =>
Material(color: Colors.transparent, child: buildUserPanel());
Widget buildTabletLeftPane(BuildContext context) => Builder(
builder: (context) =>
Material(color: Colors.transparent, child: buildUserPanel()));
Widget buildTabletRightPane(BuildContext context) {
final activeChatRemoteConversationKey =
context.watch<ActiveChatCubit>().state;
if (activeChatRemoteConversationKey == null) {
return const EmptyChatWidget();
}
return ChatComponent.builder(
remoteConversationRecordKey: activeChatRemoteConversationKey);
}
// ignore: prefer_expression_function_bodies
Widget buildTablet(BuildContext context) {
final w = MediaQuery.of(context).size.width;
final theme = Theme.of(context);
final scale = theme.extension<ScaleScheme>()!;
final children = [
ConstrainedBox(
constraints: const BoxConstraints(minWidth: 300, maxWidth: 300),
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: w / 2),
child: buildTabletLeftPane(context))),
SizedBox(
width: 2,
height: double.infinity,
child: ColoredBox(color: scale.primaryScale.hoverBorder)),
Expanded(child: buildTabletRightPane(context)),
];
return Row(
children: children,
);
}
@override
Widget build(BuildContext context) => responsiveVisibility(
context: context,
phone: false,
)
? buildTablet(context)
: buildPhone(context);
}

View File

@ -0,0 +1,159 @@
import 'package:async_tools/async_tools.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:provider/provider.dart';
import 'package:veilid_support/veilid_support.dart';
import '../../../account_manager/account_manager.dart';
import '../../../chat/chat.dart';
import '../../../chat_list/chat_list.dart';
import '../../../contact_invitation/contact_invitation.dart';
import '../../../contacts/contacts.dart';
import '../../../router/router.dart';
import '../../../tools/tools.dart';
class HomeAccountReadyShell extends StatefulWidget {
factory HomeAccountReadyShell(
{required BuildContext context, required Widget child, Key? key}) {
// These must exist in order for the account to
// be considered 'ready' for this widget subtree
final activeLocalAccount = context.read<ActiveLocalAccountCubit>().state!;
final activeAccountInfo = context.read<ActiveAccountInfo>();
final routerCubit = context.read<RouterCubit>();
return HomeAccountReadyShell._(
activeLocalAccount: activeLocalAccount,
activeAccountInfo: activeAccountInfo,
routerCubit: routerCubit,
key: key,
child: child);
}
const HomeAccountReadyShell._(
{required this.activeLocalAccount,
required this.activeAccountInfo,
required this.routerCubit,
required this.child,
super.key});
@override
HomeAccountReadyShellState createState() => HomeAccountReadyShellState();
final Widget child;
final TypedKey activeLocalAccount;
final ActiveAccountInfo activeAccountInfo;
final RouterCubit routerCubit;
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties
..add(DiagnosticsProperty<TypedKey>(
'activeLocalAccount', activeLocalAccount))
..add(DiagnosticsProperty<ActiveAccountInfo>(
'activeAccountInfo', activeAccountInfo))
..add(DiagnosticsProperty<RouterCubit>('routerCubit', routerCubit));
}
}
class HomeAccountReadyShellState extends State<HomeAccountReadyShell> {
final SingleStateProcessor<WaitingInvitationsBlocMapState>
_singleInvitationStatusProcessor = SingleStateProcessor();
@override
void initState() {
super.initState();
}
// Process all accepted or rejected invitations
void _invitationStatusListener(
BuildContext context, WaitingInvitationsBlocMapState state) {
_singleInvitationStatusProcessor.updateState(state, (newState) async {
final contactListCubit = context.read<ContactListCubit>();
final contactInvitationListCubit =
context.read<ContactInvitationListCubit>();
for (final entry in newState.entries) {
final contactRequestInboxRecordKey = entry.key;
final invStatus = entry.value.data?.value;
// Skip invitations that have not yet been accepted or rejected
if (invStatus == null) {
continue;
}
// Delete invitation and process the accepted or rejected contact
final acceptedContact = invStatus.acceptedContact;
if (acceptedContact != null) {
await contactInvitationListCubit.deleteInvitation(
accepted: true,
contactRequestInboxRecordKey: contactRequestInboxRecordKey);
// Accept
await contactListCubit.createContact(
remoteProfile: acceptedContact.remoteProfile,
remoteIdentity: acceptedContact.remoteIdentity,
remoteConversationRecordKey:
acceptedContact.remoteConversationRecordKey,
localConversationRecordKey:
acceptedContact.localConversationRecordKey,
);
} else {
// Reject
await contactInvitationListCubit.deleteInvitation(
accepted: false,
contactRequestInboxRecordKey: contactRequestInboxRecordKey);
}
}
});
}
@override
Widget build(BuildContext context) {
final account = context.watch<AccountRecordCubit>().state.data?.value;
if (account == null) {
return waitingPage();
}
return MultiBlocProvider(
providers: [
BlocProvider(
create: (context) => ContactInvitationListCubit(
activeAccountInfo: widget.activeAccountInfo,
account: account)),
BlocProvider(
create: (context) => ContactListCubit(
activeAccountInfo: widget.activeAccountInfo,
account: account)),
BlocProvider(
create: (context) => ActiveChatCubit(null)
..withStateListen((event) {
widget.routerCubit.setHasActiveChat(event != null);
})),
BlocProvider(
create: (context) => ChatListCubit(
activeAccountInfo: widget.activeAccountInfo,
activeChatCubit: context.read<ActiveChatCubit>(),
account: account)),
BlocProvider(
create: (context) => ActiveConversationsBlocMapCubit(
activeAccountInfo: widget.activeAccountInfo,
contactListCubit: context.read<ContactListCubit>())
..followBloc(context.read<ChatListCubit>())),
BlocProvider(
create: (context) => ActiveSingleContactChatBlocMapCubit(
activeAccountInfo: widget.activeAccountInfo,
contactListCubit: context.read<ContactListCubit>(),
chatListCubit: context.read<ChatListCubit>())
..followBloc(context.read<ActiveConversationsBlocMapCubit>())),
BlocProvider(
create: (context) => WaitingInvitationsBlocMapCubit(
activeAccountInfo: widget.activeAccountInfo, account: account)
..followBloc(context.read<ContactInvitationListCubit>()))
],
child: MultiBlocListener(listeners: [
BlocListener<WaitingInvitationsBlocMapCubit,
WaitingInvitationsBlocMapState>(
listener: _invitationStatusListener,
)
], child: widget.child));
}
}

View File

@ -1,47 +1,23 @@
// ignore_for_file: prefer_const_constructors
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 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_translate/flutter_translate.dart';
import '../../components/contact_invitation_list_widget.dart';
import '../../components/contact_list_widget.dart';
import '../../entities/local_account.dart';
import '../../proto/proto.dart' as proto;
import '../../providers/contact.dart';
import '../../providers/contact_invite.dart';
import '../../tools/theme_service.dart';
import '../../tools/tools.dart';
import '../../veilid_support/veilid_support.dart';
import '../../../../contact_invitation/contact_invitation.dart';
import '../../../../contacts/contacts.dart';
import '../../../../theme/theme.dart';
class AccountPage extends ConsumerStatefulWidget {
class AccountPage extends StatefulWidget {
const AccountPage({
required this.localAccounts,
required this.activeUserLogin,
required this.account,
super.key,
});
final IList<LocalAccount> localAccounts;
final TypedKey activeUserLogin;
final proto.Account account;
@override
AccountPageState createState() => AccountPageState();
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties
..add(IterableProperty<LocalAccount>('localAccounts', localAccounts))
..add(DiagnosticsProperty<TypedKey>('activeUserLogin', activeUserLogin))
..add(DiagnosticsProperty<proto.Account>('account', account));
}
}
class AccountPageState extends ConsumerState<AccountPage> {
class AccountPageState extends State<AccountPage> {
final _unfocusNode = FocusNode();
@override
@ -62,17 +38,20 @@ class AccountPageState extends ConsumerState<AccountPage> {
final textTheme = theme.textTheme;
final scale = theme.extension<ScaleScheme>()!;
final cilState = context.watch<ContactInvitationListCubit>().state;
final cilBusy = cilState.busy;
final contactInvitationRecordList =
ref.watch(fetchContactInvitationRecordsProvider).asData?.value ??
const IListConst([]);
final contactList = ref.watch(fetchContactListProvider).asData?.value ??
const IListConst([]);
cilState.state.data?.value ?? const IListConst([]);
final ciState = context.watch<ContactListCubit>().state;
final ciBusy = ciState.busy;
final contactList = ciState.state.data?.value ?? const IListConst([]);
return SizedBox(
child: Column(children: <Widget>[
if (contactInvitationRecordList.isNotEmpty)
ExpansionTile(
tilePadding: EdgeInsets.fromLTRB(8, 0, 8, 0),
tilePadding: const EdgeInsets.fromLTRB(8, 0, 8, 0),
backgroundColor: scale.primaryScale.border,
collapsedBackgroundColor: scale.primaryScale.border,
shape: RoundedRectangleBorder(
@ -90,10 +69,11 @@ class AccountPageState extends ConsumerState<AccountPage> {
initiallyExpanded: true,
children: [
ContactInvitationListWidget(
contactInvitationRecordList: contactInvitationRecordList)
contactInvitationRecordList: contactInvitationRecordList,
disabled: cilBusy)
],
).paddingLTRB(8, 0, 8, 8),
ContactListWidget(contactList: contactList).expanded(),
ContactListWidget(contactList: contactList, disabled: ciBusy).expanded(),
]));
}
}

View File

@ -1,8 +1,7 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class BottomSheetActionButton extends ConsumerStatefulWidget {
class BottomSheetActionButton extends StatefulWidget {
const BottomSheetActionButton(
{required this.bottomSheetBuilder,
required this.builder,
@ -32,8 +31,7 @@ class BottomSheetActionButton extends ConsumerStatefulWidget {
}
}
class BottomSheetActionButtonState
extends ConsumerState<BottomSheetActionButton> {
class BottomSheetActionButtonState extends State<BottomSheetActionButton> {
bool _showFab = true;
@override

View File

@ -0,0 +1,34 @@
import 'package:awesome_extensions/awesome_extensions.dart';
import 'package:flutter/material.dart';
import '../../../../chat_list/chat_list.dart';
class ChatsPage extends StatefulWidget {
const ChatsPage({super.key});
@override
ChatsPageState createState() => ChatsPageState();
}
class ChatsPageState extends State<ChatsPage> {
final _unfocusNode = FocusNode();
@override
void initState() {
super.initState();
}
@override
void dispose() {
_unfocusNode.dispose();
super.dispose();
}
@override
// ignore: prefer_expression_function_bodies
Widget build(BuildContext context) {
return Column(children: <Widget>[
const ChatSingleContactListWidget().expanded(),
]);
}
}

View File

@ -1,57 +1,31 @@
import 'dart:async';
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 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_translate/flutter_translate.dart';
import 'package:preload_page_view/preload_page_view.dart';
import 'package:stylish_bottom_bar/model/bar_items.dart';
import 'package:stylish_bottom_bar/stylish_bottom_bar.dart';
import '../../components/bottom_sheet_action_button.dart';
import '../../components/paste_invite_dialog.dart';
import '../../components/scan_invite_dialog.dart';
import '../../components/send_invite_dialog.dart';
import '../../entities/local_account.dart';
import '../../proto/proto.dart' as proto;
import '../../tools/tools.dart';
import '../../veilid_support/veilid_support.dart';
import 'account.dart';
import 'chats.dart';
import '../../../../contact_invitation/contact_invitation.dart';
import '../../../../theme/theme.dart';
import '../../../../tools/tools.dart';
import 'account_page.dart';
import 'bottom_sheet_action_button.dart';
import 'chats_page.dart';
class MainPager extends ConsumerStatefulWidget {
const MainPager(
{required this.localAccounts,
required this.activeUserLogin,
required this.account,
super.key});
final IList<LocalAccount> localAccounts;
final TypedKey activeUserLogin;
final proto.Account account;
class MainPager extends StatefulWidget {
const MainPager({super.key});
@override
MainPagerState createState() => MainPagerState();
static MainPagerState? of(BuildContext context) =>
context.findAncestorStateOfType<MainPagerState>();
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties
..add(IterableProperty<LocalAccount>('localAccounts', localAccounts))
..add(DiagnosticsProperty<TypedKey>('activeUserLogin', activeUserLogin))
..add(DiagnosticsProperty<proto.Account>('account', account));
}
}
class MainPagerState extends ConsumerState<MainPager>
with TickerProviderStateMixin {
class MainPagerState extends State<MainPager> with TickerProviderStateMixin {
//////////////////////////////////////////////////////////////////
final _unfocusNode = FocusNode();
@ -136,98 +110,41 @@ class MainPagerState extends ConsumerState<MainPager>
context: context,
// ignore: prefer_expression_function_bodies
builder: (context) {
return const AlertDialog(
shape: RoundedRectangleBorder(
return AlertDialog(
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(20)),
),
contentPadding: EdgeInsets.only(
contentPadding: const EdgeInsets.only(
top: 10,
),
title: Text(
title: const Text(
'Scan Contact Invite',
style: TextStyle(fontSize: 24),
),
content: ScanInviteDialog());
content: ScanInviteDialog(
modalContext: context,
));
});
}
Widget _newContactInvitationBottomSheetBuilder(
// ignore: prefer_expression_function_bodies
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()
])));
}
// ignore: prefer_expression_function_bodies
Widget _onNewChatBottomSheetBuilder(BuildContext context) {
return const SizedBox(
Widget _onNewChatBottomSheetBuilder(
BuildContext sheetContext, BuildContext context) =>
const SizedBox(
height: 200,
child: Center(
child: Text(
'Group and custom chat functionality is not available yet')));
}
Widget _bottomSheetBuilder(BuildContext context) {
Widget _bottomSheetBuilder(BuildContext sheetContext, BuildContext context) {
if (_currentPage == 0) {
// New contact invitation
return _newContactInvitationBottomSheetBuilder(context);
return newContactInvitationBottomSheetBuilder(sheetContext, context);
} else if (_currentPage == 1) {
// New chat
return _onNewChatBottomSheetBuilder(context);
return _onNewChatBottomSheetBuilder(sheetContext, context);
} else {
// Unknown error
return waitingPage(context);
return debugPage('unknown page');
}
}
@ -250,12 +167,9 @@ class MainPagerState extends ConsumerState<MainPager>
_currentPage = index;
});
},
children: [
AccountPage(
localAccounts: widget.localAccounts,
activeUserLogin: widget.activeUserLogin,
account: widget.account),
const ChatsPage(),
children: const [
AccountPage(),
ChatsPage(),
])),
// appBar: AppBar(
// toolbarHeight: 24,
@ -301,7 +215,8 @@ class MainPagerState extends ConsumerState<MainPager>
_fabIconList[_currentPage],
color: scale.secondaryScale.text,
),
bottomSheetBuilder: _bottomSheetBuilder),
bottomSheetBuilder: (sheetContext) =>
_bottomSheetBuilder(sheetContext, context)),
floatingActionButtonLocation: FloatingActionButtonLocation.endDocked,
);
}

View File

@ -0,0 +1,25 @@
import 'package:flutter/material.dart';
import '../../tools/tools.dart';
class HomeNoActive extends StatefulWidget {
const HomeNoActive({super.key});
@override
HomeNoActiveState createState() => HomeNoActiveState();
}
class HomeNoActiveState extends State<HomeNoActive> {
@override
void initState() {
super.initState();
}
@override
void dispose() {
super.dispose();
}
@override
Widget build(BuildContext context) => waitingPage();
}

Some files were not shown because too many files have changed in this diff Show More