mirror of
https://gitlab.com/veilid/veilidchat.git
synced 2024-12-25 23:59:32 -05:00
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:
commit
f03f373e82
@ -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
|
||||
|
17
build.sh
17
build.sh
@ -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
2
devtools_options.yaml
Normal file
@ -0,0 +1,2 @@
|
||||
extensions:
|
||||
- provider: true
|
@ -21,6 +21,6 @@
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0</string>
|
||||
<key>MinimumOSVersion</key>
|
||||
<string>11.0</string>
|
||||
<string>12.0</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
@ -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
|
||||
|
@ -155,7 +155,7 @@
|
||||
97C146E61CF9000F007C117D /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
LastUpgradeCheck = 1430;
|
||||
LastUpgradeCheck = 1510;
|
||||
ORGANIZATIONNAME = "";
|
||||
TargetAttributes = {
|
||||
97C146ED1CF9000F007C117D = {
|
||||
|
@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1430"
|
||||
LastUpgradeVersion = "1510"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
|
4
lib/account_manager/account_manager.dart
Normal file
4
lib/account_manager/account_manager.dart
Normal file
@ -0,0 +1,4 @@
|
||||
export 'cubits/cubits.dart';
|
||||
export 'models/models.dart';
|
||||
export 'repository/repository.dart';
|
||||
export 'views/views.dart';
|
16
lib/account_manager/cubits/account_record_cubit.dart
Normal file
16
lib/account_manager/cubits/account_record_cubit.dart
Normal 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();
|
||||
}
|
||||
}
|
46
lib/account_manager/cubits/active_local_account_cubit.dart
Normal file
46
lib/account_manager/cubits/active_local_account_cubit.dart
Normal 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;
|
||||
}
|
4
lib/account_manager/cubits/cubits.dart
Normal file
4
lib/account_manager/cubits/cubits.dart
Normal 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';
|
47
lib/account_manager/cubits/local_accounts_cubit.dart
Normal file
47
lib/account_manager/cubits/local_accounts_cubit.dart
Normal 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;
|
||||
}
|
47
lib/account_manager/cubits/user_logins_cubit.dart
Normal file
47
lib/account_manager/cubits/user_logins_cubit.dart
Normal 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;
|
||||
}
|
23
lib/account_manager/models/account_info.dart
Normal file
23
lib/account_manager/models/account_info.dart
Normal 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;
|
||||
}
|
45
lib/account_manager/models/active_account_info.dart
Normal file
45
lib/account_manager/models/active_account_info.dart
Normal 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;
|
||||
}
|
79
lib/account_manager/models/encryption_key_type.dart
Normal file
79
lib/account_manager/models/encryption_key_type.dart
Normal 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;
|
||||
}
|
||||
}
|
39
lib/account_manager/models/local_account/local_account.dart
Normal file
39
lib/account_manager/models/local_account/local_account.dart
Normal 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>);
|
||||
}
|
@ -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 &&
|
6
lib/account_manager/models/models.dart
Normal file
6
lib/account_manager/models/models.dart
Normal 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';
|
5
lib/account_manager/models/new_profile_spec.dart
Normal file
5
lib/account_manager/models/new_profile_spec.dart
Normal file
@ -0,0 +1,5 @@
|
||||
class NewProfileSpec {
|
||||
NewProfileSpec({required this.name, required this.pronouns});
|
||||
String name;
|
||||
String pronouns;
|
||||
}
|
@ -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>);
|
||||
}
|
@ -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;
|
||||
}
|
@ -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(),
|
||||
};
|
@ -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;
|
||||
}
|
||||
}
|
1
lib/account_manager/repository/repository.dart
Normal file
1
lib/account_manager/repository/repository.dart
Normal file
@ -0,0 +1 @@
|
||||
export 'account_repository/account_repository.dart';
|
@ -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'),
|
43
lib/account_manager/views/profile_widget.dart
Normal file
43
lib/account_manager/views/profile_widget.dart
Normal 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),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
2
lib/account_manager/views/views.dart
Normal file
2
lib/account_manager/views/views.dart
Normal file
@ -0,0 +1,2 @@
|
||||
export 'new_account_page/new_account_page.dart';
|
||||
export 'profile_widget.dart';
|
50
lib/app.dart
50
lib/app.dart
@ -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
2
lib/chat/chat.dart
Normal file
@ -0,0 +1,2 @@
|
||||
export 'cubits/cubits.dart';
|
||||
export 'views/views.dart';
|
11
lib/chat/cubits/active_chat_cubit.dart
Normal file
11
lib/chat/cubits/active_chat_cubit.dart
Normal 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);
|
||||
}
|
||||
}
|
2
lib/chat/cubits/cubits.dart
Normal file
2
lib/chat/cubits/cubits.dart
Normal file
@ -0,0 +1,2 @@
|
||||
export 'active_chat_cubit.dart';
|
||||
export 'single_contact_messages_cubit.dart';
|
278
lib/chat/cubits/single_contact_messages_cubit.dart
Normal file
278
lib/chat/cubits/single_contact_messages_cubit.dart
Normal 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;
|
||||
}
|
211
lib/chat/views/chat_component.dart
Normal file
211
lib/chat/views/chat_component.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
34
lib/chat/views/empty_chat_widget.dart
Normal file
34
lib/chat/views/empty_chat_widget.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
34
lib/chat/views/no_conversation_widget.dart
Normal file
34
lib/chat/views/no_conversation_widget.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
3
lib/chat/views/views.dart
Normal file
3
lib/chat/views/views.dart
Normal file
@ -0,0 +1,3 @@
|
||||
export 'chat_component.dart';
|
||||
export 'empty_chat_widget.dart';
|
||||
export 'no_conversation_widget.dart';
|
2
lib/chat_list/chat_list.dart
Normal file
2
lib/chat_list/chat_list.dart
Normal file
@ -0,0 +1,2 @@
|
||||
export 'cubits/cubits.dart';
|
||||
export 'views/views.dart';
|
116
lib/chat_list/cubits/active_conversations_bloc_map_cubit.dart
Normal file
116
lib/chat_list/cubits/active_conversations_bloc_map_cubit.dart
Normal 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;
|
||||
}
|
@ -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;
|
||||
}
|
121
lib/chat_list/cubits/chat_list_cubit.dart
Normal file
121
lib/chat_list/cubits/chat_list_cubit.dart
Normal 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;
|
||||
}
|
3
lib/chat_list/cubits/cubits.dart
Normal file
3
lib/chat_list/cubits/cubits.dart
Normal 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';
|
@ -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));
|
||||
}
|
||||
}
|
83
lib/chat_list/views/chat_single_contact_list_widget.dart
Normal file
83
lib/chat_list/views/chat_single_contact_list_widget.dart
Normal 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));
|
||||
});
|
||||
}
|
||||
}
|
@ -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>()!;
|
3
lib/chat_list/views/views.dart
Normal file
3
lib/chat_list/views/views.dart
Normal 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';
|
@ -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)
|
||||
]);
|
||||
}
|
||||
}
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
@ -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));
|
||||
}
|
||||
}
|
@ -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));
|
||||
}
|
||||
}
|
@ -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));
|
||||
}
|
||||
}
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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));
|
||||
}
|
||||
}
|
@ -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,
|
||||
));
|
||||
}
|
||||
}
|
3
lib/contact_invitation/contact_invitation.dart
Normal file
3
lib/contact_invitation/contact_invitation.dart
Normal file
@ -0,0 +1,3 @@
|
||||
export 'cubits/cubits.dart';
|
||||
export 'models/models.dart';
|
||||
export 'views/views.dart';
|
288
lib/contact_invitation/cubits/contact_invitation_list_cubit.dart
Normal file
288
lib/contact_invitation/cubits/contact_invitation_list_cubit.dart
Normal 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;
|
||||
}
|
@ -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;
|
||||
}
|
5
lib/contact_invitation/cubits/cubits.dart
Normal file
5
lib/contact_invitation/cubits/cubits.dart
Normal 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';
|
@ -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();
|
||||
}
|
111
lib/contact_invitation/cubits/waiting_invitation_cubit.dart
Normal file
111
lib/contact_invitation/cubits/waiting_invitation_cubit.dart
Normal 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)));
|
||||
});
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
28
lib/contact_invitation/models/accepted_contact.dart
Normal file
28
lib/contact_invitation/models/accepted_contact.dart
Normal 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
|
||||
];
|
||||
}
|
2
lib/contact_invitation/models/models.dart
Normal file
2
lib/contact_invitation/models/models.dart
Normal file
@ -0,0 +1,2 @@
|
||||
export 'accepted_contact.dart';
|
||||
export 'valid_contact_invitation.dart';
|
146
lib/contact_invitation/models/valid_contact_invitation.dart
Normal file
146
lib/contact_invitation/models/valid_contact_invitation.dart
Normal 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;
|
||||
}
|
132
lib/contact_invitation/views/contact_invitation_display.dart
Normal file
132
lib/contact_invitation/views/contact_invitation_display.dart
Normal 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));
|
||||
}
|
||||
}
|
@ -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
|
@ -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);
|
||||
},
|
@ -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: [
|
@ -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()
|
||||
])));
|
||||
}
|
@ -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,
|
@ -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,
|
@ -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();
|
||||
}
|
||||
|
8
lib/contact_invitation/views/views.dart
Normal file
8
lib/contact_invitation/views/views.dart
Normal 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';
|
2
lib/contacts/contacts.dart
Normal file
2
lib/contacts/contacts.dart
Normal file
@ -0,0 +1,2 @@
|
||||
export 'cubits/cubits.dart';
|
||||
export 'views/views.dart';
|
101
lib/contacts/cubits/contact_list_cubit.dart
Normal file
101
lib/contacts/cubits/contact_list_cubit.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
292
lib/contacts/cubits/conversation_cubit.dart
Normal file
292
lib/contacts/cubits/conversation_cubit.dart
Normal 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;
|
||||
}
|
2
lib/contacts/cubits/cubits.dart
Normal file
2
lib/contacts/cubits/cubits.dart
Normal file
@ -0,0 +1,2 @@
|
||||
export 'contact_list_cubit.dart';
|
||||
export 'conversation_cubit.dart';
|
109
lib/contacts/views/contact_item_widget.dart
Normal file
109
lib/contacts/views/contact_item_widget.dart
Normal 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));
|
||||
}
|
||||
}
|
@ -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
|
@ -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>()!;
|
3
lib/contacts/views/views.dart
Normal file
3
lib/contacts/views/views.dart
Normal file
@ -0,0 +1,3 @@
|
||||
export 'contact_item_widget.dart';
|
||||
export 'contact_list_widget.dart';
|
||||
export 'empty_contact_list_widget.dart';
|
@ -1,3 +0,0 @@
|
||||
export 'local_account.dart';
|
||||
export 'preferences.dart';
|
||||
export 'user_login.dart';
|
@ -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
41
lib/init.dart
Normal 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();
|
||||
}
|
6
lib/layout/home/home.dart
Normal file
6
lib/layout/home/home.dart
Normal 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';
|
32
lib/layout/home/home_account_invalid.dart
Normal file
32
lib/layout/home/home_account_invalid.dart
Normal 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);
|
||||
// });
|
23
lib/layout/home/home_account_locked.dart
Normal file
23
lib/layout/home/home_account_locked.dart
Normal 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');
|
||||
}
|
33
lib/layout/home/home_account_missing.dart
Normal file
33
lib/layout/home/home_account_missing.dart
Normal 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);
|
||||
// });
|
@ -0,0 +1,3 @@
|
||||
export 'home_account_ready_chat.dart';
|
||||
export 'home_account_ready_main.dart';
|
||||
export 'home_account_ready_shell.dart';
|
@ -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),
|
||||
));
|
||||
}
|
109
lib/layout/home/home_account_ready/home_account_ready_main.dart
Normal file
109
lib/layout/home/home_account_ready/home_account_ready_main.dart
Normal 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);
|
||||
}
|
159
lib/layout/home/home_account_ready/home_account_ready_shell.dart
Normal file
159
lib/layout/home/home_account_ready/home_account_ready_shell.dart
Normal 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));
|
||||
}
|
||||
}
|
@ -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(),
|
||||
]));
|
||||
}
|
||||
}
|
@ -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
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
@ -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,
|
||||
);
|
||||
}
|
25
lib/layout/home/home_no_active.dart
Normal file
25
lib/layout/home/home_no_active.dart
Normal 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
Loading…
Reference in New Issue
Block a user