diff --git a/build.bat b/build.bat
index 0889dcf..e40afe0 100644
--- a/build.bat
+++ b/build.bat
@@ -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
diff --git a/build.sh b/build.sh
index 96cce52..f723071 100755
--- a/build.sh
+++ b/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
\ No newline at end of file
diff --git a/devtools_options.yaml b/devtools_options.yaml
new file mode 100644
index 0000000..5c27c3e
--- /dev/null
+++ b/devtools_options.yaml
@@ -0,0 +1,2 @@
+extensions:
+ - provider: true
\ No newline at end of file
diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist
index 9625e10..7c56964 100644
--- a/ios/Flutter/AppFrameworkInfo.plist
+++ b/ios/Flutter/AppFrameworkInfo.plist
@@ -21,6 +21,6 @@
CFBundleVersion
1.0
MinimumOSVersion
- 11.0
+ 12.0
diff --git a/ios/Podfile b/ios/Podfile
index bd3431c..2cbcaa2 100644
--- a/ios/Podfile
+++ b/ios/Podfile
@@ -44,4 +44,4 @@ post_install do |installer|
File.open(xcconfig_path, "w") { |file| file << xcconfig_mod }
end
end
-end
\ No newline at end of file
+end
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index 125a3a8..636ef83 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -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
diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj
index 3100ff0..72c3178 100644
--- a/ios/Runner.xcodeproj/project.pbxproj
+++ b/ios/Runner.xcodeproj/project.pbxproj
@@ -155,7 +155,7 @@
97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject;
attributes = {
- LastUpgradeCheck = 1430;
+ LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
97C146ED1CF9000F007C117D = {
diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
index a6b826d..5e31d3d 100644
--- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
+++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
@@ -1,6 +1,6 @@
{
+ AccountRecordCubit({
+ required super.open,
+ }) : super(decodeState: proto.Account.fromBuffer);
+
+ @override
+ Future close() async {
+ await super.close();
+ }
+}
diff --git a/lib/account_manager/cubits/active_local_account_cubit.dart b/lib/account_manager/cubits/active_local_account_cubit.dart
new file mode 100644
index 0000000..29a76c9
--- /dev/null
+++ b/lib/account_manager/cubits/active_local_account_cubit.dart
@@ -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 {
+ 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 close() async {
+ await super.close();
+ await _accountRepositorySubscription.cancel();
+ }
+
+ final AccountRepository _accountRepository;
+ late final StreamSubscription
+ _accountRepositorySubscription;
+}
diff --git a/lib/account_manager/cubits/cubits.dart b/lib/account_manager/cubits/cubits.dart
new file mode 100644
index 0000000..6d2875d
--- /dev/null
+++ b/lib/account_manager/cubits/cubits.dart
@@ -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';
diff --git a/lib/account_manager/cubits/local_accounts_cubit.dart b/lib/account_manager/cubits/local_accounts_cubit.dart
new file mode 100644
index 0000000..376c810
--- /dev/null
+++ b/lib/account_manager/cubits/local_accounts_cubit.dart
@@ -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> {
+ LocalAccountsCubit(AccountRepository accountRepository)
+ : _accountRepository = accountRepository,
+ super(IList()) {
+ // 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 close() async {
+ await super.close();
+ await _accountRepositorySubscription.cancel();
+ }
+
+ final AccountRepository _accountRepository;
+ late final StreamSubscription
+ _accountRepositorySubscription;
+}
diff --git a/lib/account_manager/cubits/user_logins_cubit.dart b/lib/account_manager/cubits/user_logins_cubit.dart
new file mode 100644
index 0000000..30269c1
--- /dev/null
+++ b/lib/account_manager/cubits/user_logins_cubit.dart
@@ -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> {
+ UserLoginsCubit(AccountRepository accountRepository)
+ : _accountRepository = accountRepository,
+ super(IList()) {
+ // 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 close() async {
+ await super.close();
+ await _accountRepositorySubscription.cancel();
+ }
+
+ final AccountRepository _accountRepository;
+ late final StreamSubscription
+ _accountRepositorySubscription;
+}
diff --git a/lib/account_manager/models/account_info.dart b/lib/account_manager/models/account_info.dart
new file mode 100644
index 0000000..7f2e058
--- /dev/null
+++ b/lib/account_manager/models/account_info.dart
@@ -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;
+}
diff --git a/lib/account_manager/models/active_account_info.dart b/lib/account_manager/models/active_account_info.dart
new file mode 100644
index 0000000..7a1437b
--- /dev/null
+++ b/lib/account_manager/models/active_account_info.dart
@@ -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 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;
+}
diff --git a/lib/account_manager/models/encryption_key_type.dart b/lib/account_manager/models/encryption_key_type.dart
new file mode 100644
index 0000000..22897b4
--- /dev/null
+++ b/lib/account_manager/models/encryption_key_type.dart
@@ -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 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 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;
+ }
+}
diff --git a/lib/account_manager/models/local_account/local_account.dart b/lib/account_manager/models/local_account/local_account.dart
new file mode 100644
index 0000000..1998961
--- /dev/null
+++ b/lib/account_manager/models/local_account/local_account.dart
@@ -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);
+}
diff --git a/lib/entities/local_account.freezed.dart b/lib/account_manager/models/local_account/local_account.freezed.dart
similarity index 99%
rename from lib/entities/local_account.freezed.dart
rename to lib/account_manager/models/local_account/local_account.freezed.dart
index 19dcd76..781d1de 100644
--- a/lib/entities/local_account.freezed.dart
+++ b/lib/account_manager/models/local_account/local_account.freezed.dart
@@ -12,7 +12,7 @@ part of 'local_account.dart';
T _$identity(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 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 &&
diff --git a/lib/entities/local_account.g.dart b/lib/account_manager/models/local_account/local_account.g.dart
similarity index 100%
rename from lib/entities/local_account.g.dart
rename to lib/account_manager/models/local_account/local_account.g.dart
diff --git a/lib/account_manager/models/models.dart b/lib/account_manager/models/models.dart
new file mode 100644
index 0000000..d4b0ab5
--- /dev/null
+++ b/lib/account_manager/models/models.dart
@@ -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';
diff --git a/lib/account_manager/models/new_profile_spec.dart b/lib/account_manager/models/new_profile_spec.dart
new file mode 100644
index 0000000..173a382
--- /dev/null
+++ b/lib/account_manager/models/new_profile_spec.dart
@@ -0,0 +1,5 @@
+class NewProfileSpec {
+ NewProfileSpec({required this.name, required this.pronouns});
+ String name;
+ String pronouns;
+}
diff --git a/lib/entities/user_login.dart b/lib/account_manager/models/user_login/user_login.dart
similarity index 56%
rename from lib/entities/user_login.dart
rename to lib/account_manager/models/user_login/user_login.dart
index 55a4fb2..4e23184 100644
--- a/lib/entities/user_login.dart
+++ b/lib/account_manager/models/user_login/user_login.dart
@@ -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);
}
-
-// 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 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);
-}
diff --git a/lib/entities/user_login.freezed.dart b/lib/account_manager/models/user_login/user_login.freezed.dart
similarity index 63%
rename from lib/entities/user_login.freezed.dart
rename to lib/account_manager/models/user_login/user_login.freezed.dart
index aca29fc..a25b4ab 100644
--- a/lib/entities/user_login.freezed.dart
+++ b/lib/account_manager/models/user_login/user_login.freezed.dart
@@ -12,7 +12,7 @@ part of 'user_login.dart';
T _$identity(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 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 json) {
- return _ActiveLogins.fromJson(json);
-}
-
-/// @nodoc
-mixin _$ActiveLogins {
-// The list of current logged in accounts
- IList get userLogins =>
- throw _privateConstructorUsedError; // The current selected account indexed by master record key
- Typed? get activeUserLogin =>
- throw _privateConstructorUsedError;
-
- Map toJson() => throw _privateConstructorUsedError;
- @JsonKey(ignore: true)
- $ActiveLoginsCopyWith get copyWith =>
- throw _privateConstructorUsedError;
-}
-
-/// @nodoc
-abstract class $ActiveLoginsCopyWith<$Res> {
- factory $ActiveLoginsCopyWith(
- ActiveLogins value, $Res Function(ActiveLogins) then) =
- _$ActiveLoginsCopyWithImpl<$Res, ActiveLogins>;
- @useResult
- $Res call(
- {IList userLogins,
- Typed? 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,
- activeUserLogin: freezed == activeUserLogin
- ? _value.activeUserLogin
- : activeUserLogin // ignore: cast_nullable_to_non_nullable
- as Typed?,
- ) 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 userLogins,
- Typed? 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,
- activeUserLogin: freezed == activeUserLogin
- ? _value.activeUserLogin
- : activeUserLogin // ignore: cast_nullable_to_non_nullable
- as Typed?,
- ));
- }
-}
-
-/// @nodoc
-@JsonSerializable()
-class _$ActiveLoginsImpl implements _ActiveLogins {
- const _$ActiveLoginsImpl({required this.userLogins, this.activeUserLogin});
-
- factory _$ActiveLoginsImpl.fromJson(Map json) =>
- _$$ActiveLoginsImplFromJson(json);
-
-// The list of current logged in accounts
- @override
- final IList userLogins;
-// The current selected account indexed by master record key
- @override
- final Typed? 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 toJson() {
- return _$$ActiveLoginsImplToJson(
- this,
- );
- }
-}
-
-abstract class _ActiveLogins implements ActiveLogins {
- const factory _ActiveLogins(
- {required final IList userLogins,
- final Typed? activeUserLogin}) = _$ActiveLoginsImpl;
-
- factory _ActiveLogins.fromJson(Map json) =
- _$ActiveLoginsImpl.fromJson;
-
- @override // The list of current logged in accounts
- IList get userLogins;
- @override // The current selected account indexed by master record key
- Typed? get activeUserLogin;
- @override
- @JsonKey(ignore: true)
- _$$ActiveLoginsImplCopyWith<_$ActiveLoginsImpl> get copyWith =>
- throw _privateConstructorUsedError;
-}
diff --git a/lib/entities/user_login.g.dart b/lib/account_manager/models/user_login/user_login.g.dart
similarity index 62%
rename from lib/entities/user_login.g.dart
rename to lib/account_manager/models/user_login/user_login.g.dart
index a2b2143..267fc55 100644
--- a/lib/entities/user_login.g.dart
+++ b/lib/account_manager/models/user_login/user_login.g.dart
@@ -24,20 +24,3 @@ Map _$$UserLoginImplToJson(_$UserLoginImpl instance) =>
'account_record_info': instance.accountRecordInfo.toJson(),
'last_active': instance.lastActive.toJson(),
};
-
-_$ActiveLoginsImpl _$$ActiveLoginsImplFromJson(Map json) =>
- _$ActiveLoginsImpl(
- userLogins: IList.fromJson(
- json['user_logins'], (value) => UserLogin.fromJson(value)),
- activeUserLogin: json['active_user_login'] == null
- ? null
- : Typed.fromJson(json['active_user_login']),
- );
-
-Map _$$ActiveLoginsImplToJson(_$ActiveLoginsImpl instance) =>
- {
- 'user_logins': instance.userLogins.toJson(
- (value) => value.toJson(),
- ),
- 'active_user_login': instance.activeUserLogin?.toJson(),
- };
diff --git a/lib/account_manager/repository/account_repository/account_repository.dart b/lib/account_manager/repository/account_repository/account_repository.dart
new file mode 100644
index 0000000..a13b331
--- /dev/null
+++ b/lib/account_manager/repository/account_repository/account_repository.dart
@@ -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.broadcast();
+
+ static TableDBValue> _initLocalAccounts() => TableDBValue(
+ tableName: 'local_account_manager',
+ tableKeyName: 'local_accounts',
+ valueFromJson: (obj) => obj != null
+ ? IList.fromJson(
+ obj, genericFromJson(LocalAccount.fromJson))
+ : IList(),
+ valueToJson: (val) => val.toJson((la) => la.toJson()));
+
+ static TableDBValue> _initUserLogins() => TableDBValue(
+ tableName: 'local_account_manager',
+ tableKeyName: 'user_logins',
+ valueFromJson: (obj) => obj != null
+ ? IList.fromJson(obj, genericFromJson(UserLogin.fromJson))
+ : IList(),
+ valueToJson: (val) => val.toJson((la) => la.toJson()));
+
+ static TableDBValue _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> _localAccounts;
+ final TableDBValue> _userLogins;
+ final TableDBValue _activeLocalAccount;
+ final StreamController _streamController;
+
+ //////////////////////////////////////////////////////////////
+ /// Singleton initialization
+
+ static AccountRepository instance = AccountRepository._();
+
+ Future init() async {
+ await _localAccounts.get();
+ await _userLogins.get();
+ await _activeLocalAccount.get();
+ }
+
+ Future close() async {
+ // ???
+ }
+
+ //////////////////////////////////////////////////////////////
+ /// Streams
+
+ Stream get stream => _streamController.stream;
+
+ //////////////////////////////////////////////////////////////
+ /// Selectors
+ IList getLocalAccounts() => _localAccounts.requireValue;
+ TypedKey? getActiveLocalAccount() => _activeLocalAccount.requireValue;
+ IList 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 reorderAccount(int oldIndex, int newIndex) async {
+ final localAccounts = await _localAccounts.get();
+ final removedItem = Output();
+ 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 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 _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 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 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 _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 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 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 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;
+ }
+}
diff --git a/lib/account_manager/repository/repository.dart b/lib/account_manager/repository/repository.dart
new file mode 100644
index 0000000..9d1b9fe
--- /dev/null
+++ b/lib/account_manager/repository/repository.dart
@@ -0,0 +1 @@
+export 'account_repository/account_repository.dart';
diff --git a/lib/pages/new_account.dart b/lib/account_manager/views/new_account_page/new_account_page.dart
similarity index 66%
rename from lib/pages/new_account.dart
rename to lib/account_manager/views/new_account_page/new_account_page.dart
index 3ef1b6d..f81f30d 100644
--- a/lib/pages/new_account.dart
+++ b/lib/account_manager/views/new_account_page/new_account_page.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 {
+class NewAccountPageState extends State {
final _formKey = GlobalKey();
late bool isInAsyncCall = false;
static const String formFieldName = 'name';
@@ -34,42 +29,11 @@ class NewAccountPageState extends ConsumerState {
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 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 Function(GlobalKey)
onSubmit}) =>
@@ -90,12 +54,14 @@ class NewAccountPageState extends ConsumerState {
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 {
@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 {
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 {
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'),
diff --git a/lib/account_manager/views/profile_widget.dart b/lib/account_manager/views/profile_widget.dart
new file mode 100644
index 0000000..5d85014
--- /dev/null
+++ b/lib/account_manager/views/profile_widget.dart
@@ -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()!;
+ 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),
+ ]),
+ );
+ }
+}
diff --git a/lib/account_manager/views/views.dart b/lib/account_manager/views/views.dart
new file mode 100644
index 0000000..a10db1b
--- /dev/null
+++ b/lib/account_manager/views/views.dart
@@ -0,0 +1,2 @@
+export 'new_account_page/new_account_page.dart';
+export 'profile_widget.dart';
diff --git a/lib/app.dart b/lib/app.dart
index 957f46f..6624602 100644
--- a/lib/app.dart
+++ b/lib/app.dart
@@ -1,52 +1,84 @@
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: BackgroundTicker(
- builder: (context) => MaterialApp.router(
- debugShowCheckedModeBanner: false,
- routerConfig: router,
- title: translate('app.title'),
- theme: theme,
- localizationsDelegates: [
- GlobalMaterialLocalizations.delegate,
- GlobalWidgetsLocalizations.delegate,
- FormBuilderLocalizations.delegate,
- localizationDelegate
- ],
- supportedLocales: localizationDelegate.supportedLocales,
- locale: localizationDelegate.currentLocale,
- )),
+ child: MultiBlocProvider(
+ providers: [
+ BlocProvider(
+ create: (context) =>
+ ConnectionStateCubit(ProcessorRepository.instance)),
+ BlocProvider(
+ create: (context) =>
+ RouterCubit(AccountRepository.instance),
+ ),
+ BlocProvider(
+ create: (context) =>
+ LocalAccountsCubit(AccountRepository.instance),
+ ),
+ BlocProvider(
+ create: (context) =>
+ UserLoginsCubit(AccountRepository.instance),
+ ),
+ BlocProvider(
+ create: (context) =>
+ ActiveLocalAccountCubit(AccountRepository.instance),
+ ),
+ BlocProvider(
+ create: (context) =>
+ PreferencesCubit(PreferencesRepository.instance),
+ )
+ ],
+ child: BackgroundTicker(
+ builder: (context) => MaterialApp.router(
+ debugShowCheckedModeBanner: false,
+ routerConfig: context.watch().router(),
+ title: translate('app.title'),
+ theme: theme,
+ localizationsDelegates: [
+ GlobalMaterialLocalizations.delegate,
+ GlobalWidgetsLocalizations.delegate,
+ FormBuilderLocalizations.delegate,
+ localizationDelegate
+ ],
+ supportedLocales: localizationDelegate.supportedLocales,
+ locale: localizationDelegate.currentLocale,
+ ),
+ )),
));
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
- properties.add(DiagnosticsProperty('theme', theme));
+ properties
+ .add(DiagnosticsProperty('themeData', initialThemeData));
}
}
diff --git a/lib/chat/chat.dart b/lib/chat/chat.dart
new file mode 100644
index 0000000..6acdd43
--- /dev/null
+++ b/lib/chat/chat.dart
@@ -0,0 +1,2 @@
+export 'cubits/cubits.dart';
+export 'views/views.dart';
diff --git a/lib/chat/cubits/active_chat_cubit.dart b/lib/chat/cubits/active_chat_cubit.dart
new file mode 100644
index 0000000..b57076e
--- /dev/null
+++ b/lib/chat/cubits/active_chat_cubit.dart
@@ -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 with BlocTools {
+ ActiveChatCubit(super.initialState);
+
+ void setActiveChat(TypedKey? activeChatRemoteConversationRecordKey) {
+ emit(activeChatRemoteConversationRecordKey);
+ }
+}
diff --git a/lib/chat/cubits/cubits.dart b/lib/chat/cubits/cubits.dart
new file mode 100644
index 0000000..b80767f
--- /dev/null
+++ b/lib/chat/cubits/cubits.dart
@@ -0,0 +1,2 @@
+export 'active_chat_cubit.dart';
+export 'single_contact_messages_cubit.dart';
diff --git a/lib/chat/cubits/single_contact_messages_cubit.dart b/lib/chat/cubits/single_contact_messages_cubit.dart
new file mode 100644
index 0000000..4427b89
--- /dev/null
+++ b/lib/chat/cubits/single_contact_messages_cubit.dart
@@ -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? localMessages;
+ IList? remoteMessages;
+}
+
+typedef SingleContactMessagesState = AsyncValue>;
+
+// Cubit that processes single-contact chats
+// Builds the reconciled chat record from the local and remote conversation keys
+class SingleContactMessagesCubit extends Cubit {
+ 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 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 _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 _initMessagesCrypto() async {
+ _messagesCrypto = await _activeAccountInfo
+ .makeConversationCrypto(_remoteIdentityPublicKey);
+ }
+
+ // Open local messages key
+ Future _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 _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 _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>> 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>> 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>> avmessages) {
+ // When reconciled messages are updated, pass this
+ // directly to the messages cubit state
+ emit(avmessages.state);
+ }
+
+ Future _mergeMessagesInner(
+ {required DHTShortArrayWrite reconciledMessagesWriter,
+ required IList 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 _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 refresh() async {
+ final lcc = _localMessagesCubit;
+ final rcc = _remoteMessagesCubit;
+
+ if (lcc != null) {
+ await lcc.refresh();
+ }
+ if (rcc != null) {
+ await rcc.refresh();
+ }
+ }
+
+ Future 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? _localMessagesCubit;
+ DHTShortArrayCubit? _remoteMessagesCubit;
+ DHTShortArrayCubit? _reconciledChatMessagesCubit;
+
+ final StreamController<_SingleContactMessageQueueEntry> _messagesUpdateQueue;
+
+ StreamSubscription>>>?
+ _localSubscription;
+ StreamSubscription>>>?
+ _remoteSubscription;
+ StreamSubscription>>>?
+ _reconciledChatSubscription;
+}
diff --git a/lib/chat/views/chat_component.dart b/lib/chat/views/chat_component.dart
new file mode 100644
index 0000000..146185b
--- /dev/null
+++ b/lib/chat/views/chat_component.dart
@@ -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();
+ final accountRecordInfo =
+ context.watch().state.data?.value;
+ if (accountRecordInfo == null) {
+ return debugPage('should always have an account record here');
+ }
+ final contactList =
+ context.watch().state.state.data?.value;
+ if (contactList == null) {
+ return debugPage('should always have a contact list here');
+ }
+ final avconversation = context.select?>(
+ (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(
+ (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 _addMessage(proto.Message message) async {
+ if (message.text.isEmpty) {
+ return;
+ }
+ await _messagesCubit.addMessage(message: message);
+ }
+
+ Future _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 _handleAttachmentPressed() async {
+ //
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ final scale = theme.extension()!;
+ 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 = [];
+ 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().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,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ));
+ }
+}
diff --git a/lib/chat/views/empty_chat_widget.dart b/lib/chat/views/empty_chat_widget.dart
new file mode 100644
index 0000000..a9072cd
--- /dev/null
+++ b/lib/chat/views/empty_chat_widget.dart
@@ -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,
+ ),
+ ),
+ ],
+ ),
+ );
+}
diff --git a/lib/chat/views/no_conversation_widget.dart b/lib/chat/views/no_conversation_widget.dart
new file mode 100644
index 0000000..1b8545f
--- /dev/null
+++ b/lib/chat/views/no_conversation_widget.dart
@@ -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,
+ ),
+ ),
+ ],
+ ),
+ );
+}
diff --git a/lib/chat/views/views.dart b/lib/chat/views/views.dart
new file mode 100644
index 0000000..6230e65
--- /dev/null
+++ b/lib/chat/views/views.dart
@@ -0,0 +1,3 @@
+export 'chat_component.dart';
+export 'empty_chat_widget.dart';
+export 'no_conversation_widget.dart';
diff --git a/lib/chat_list/chat_list.dart b/lib/chat_list/chat_list.dart
new file mode 100644
index 0000000..6acdd43
--- /dev/null
+++ b/lib/chat_list/chat_list.dart
@@ -0,0 +1,2 @@
+export 'cubits/cubits.dart';
+export 'views/views.dart';
diff --git a/lib/chat_list/cubits/active_conversations_bloc_map_cubit.dart b/lib/chat_list/cubits/active_conversations_bloc_map_cubit.dart
new file mode 100644
index 0000000..8ce919a
--- /dev/null
+++ b/lib/chat_list/cubits/active_conversations_bloc_map_cubit.dart
@@ -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