mirror of
https://gitlab.com/veilid/veilidchat.git
synced 2024-12-26 08:09:37 -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
|
@echo off
|
||||||
dart run build_runner build --delete-conflicting-outputs
|
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
|
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 -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 -I veilid_support\dht_support\proto dht.proto
|
||||||
|
17
build.sh
17
build.sh
@ -1,9 +1,16 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
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
|
dart run build_runner build --delete-conflicting-outputs
|
||||||
|
|
||||||
pushd lib > /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
|
||||||
protoc --dart_out=proto -I veilid_support/proto -I veilid_support/dht_support/proto -I proto veilidchat.proto
|
sed -i '' 's/dht.pb.dart/package:veilid_support\/proto\/dht.pb.dart/g' lib/proto/veilidchat.pb.dart
|
||||||
protoc --dart_out=proto -I veilid_support/proto -I veilid_support/dht_support/proto dht.proto
|
sed -i '' 's/veilid.pb.dart/package:veilid_support\/proto\/veilid.pb.dart/g' lib/proto/veilidchat.pb.dart
|
||||||
protoc --dart_out=proto -I veilid_support/proto veilid.proto
|
|
||||||
popd > /dev/null
|
|
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>
|
<key>CFBundleVersion</key>
|
||||||
<string>1.0</string>
|
<string>1.0</string>
|
||||||
<key>MinimumOSVersion</key>
|
<key>MinimumOSVersion</key>
|
||||||
<string>11.0</string>
|
<string>12.0</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
@ -4,9 +4,6 @@ PODS:
|
|||||||
- Flutter (1.0.0)
|
- Flutter (1.0.0)
|
||||||
- flutter_native_splash (0.0.1):
|
- flutter_native_splash (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- FMDB (2.7.5):
|
|
||||||
- FMDB/standard (= 2.7.5)
|
|
||||||
- FMDB/standard (2.7.5)
|
|
||||||
- GoogleDataTransport (9.2.5):
|
- GoogleDataTransport (9.2.5):
|
||||||
- GoogleUtilities/Environment (~> 7.7)
|
- GoogleUtilities/Environment (~> 7.7)
|
||||||
- nanopb (< 2.30910.0, >= 2.30908.0)
|
- nanopb (< 2.30910.0, >= 2.30908.0)
|
||||||
@ -55,7 +52,7 @@ PODS:
|
|||||||
- GTMSessionFetcher/Core (< 3.0, >= 1.1)
|
- GTMSessionFetcher/Core (< 3.0, >= 1.1)
|
||||||
- MLImage (= 1.0.0-beta4)
|
- MLImage (= 1.0.0-beta4)
|
||||||
- MLKitCommon (~> 9.0)
|
- MLKitCommon (~> 9.0)
|
||||||
- mobile_scanner (3.2.0):
|
- mobile_scanner (3.5.6):
|
||||||
- Flutter
|
- Flutter
|
||||||
- GoogleMLKit/BarcodeScanning (~> 4.0.0)
|
- GoogleMLKit/BarcodeScanning (~> 4.0.0)
|
||||||
- nanopb (2.30909.0):
|
- nanopb (2.30909.0):
|
||||||
@ -78,7 +75,7 @@ PODS:
|
|||||||
- Flutter
|
- Flutter
|
||||||
- sqflite (0.0.3):
|
- sqflite (0.0.3):
|
||||||
- Flutter
|
- Flutter
|
||||||
- FMDB (>= 2.7.5)
|
- FlutterMacOS
|
||||||
- system_info_plus (0.0.1):
|
- system_info_plus (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- url_launcher_ios (0.0.1):
|
- url_launcher_ios (0.0.1):
|
||||||
@ -96,14 +93,13 @@ DEPENDENCIES:
|
|||||||
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
||||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||||
- smart_auth (from `.symlinks/plugins/smart_auth/ios`)
|
- 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`)
|
- system_info_plus (from `.symlinks/plugins/system_info_plus/ios`)
|
||||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||||
- veilid (from `.symlinks/plugins/veilid/ios`)
|
- veilid (from `.symlinks/plugins/veilid/ios`)
|
||||||
|
|
||||||
SPEC REPOS:
|
SPEC REPOS:
|
||||||
trunk:
|
trunk:
|
||||||
- FMDB
|
|
||||||
- GoogleDataTransport
|
- GoogleDataTransport
|
||||||
- GoogleMLKit
|
- GoogleMLKit
|
||||||
- GoogleToolboxForMac
|
- GoogleToolboxForMac
|
||||||
@ -137,7 +133,7 @@ EXTERNAL SOURCES:
|
|||||||
smart_auth:
|
smart_auth:
|
||||||
:path: ".symlinks/plugins/smart_auth/ios"
|
:path: ".symlinks/plugins/smart_auth/ios"
|
||||||
sqflite:
|
sqflite:
|
||||||
:path: ".symlinks/plugins/sqflite/ios"
|
:path: ".symlinks/plugins/sqflite/darwin"
|
||||||
system_info_plus:
|
system_info_plus:
|
||||||
:path: ".symlinks/plugins/system_info_plus/ios"
|
:path: ".symlinks/plugins/system_info_plus/ios"
|
||||||
url_launcher_ios:
|
url_launcher_ios:
|
||||||
@ -146,10 +142,9 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/veilid/ios"
|
:path: ".symlinks/plugins/veilid/ios"
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
camera_avfoundation: 3125e8cd1a4387f6f31c6c63abb8a55892a9eeeb
|
camera_avfoundation: 759172d1a77ae7be0de08fc104cfb79738b8a59e
|
||||||
Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854
|
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
|
||||||
flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef
|
flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef
|
||||||
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
|
|
||||||
GoogleDataTransport: 54dee9d48d14580407f8f5fbf2f496e92437a2f2
|
GoogleDataTransport: 54dee9d48d14580407f8f5fbf2f496e92437a2f2
|
||||||
GoogleMLKit: 2bd0dc6253c4d4f227aad460f69215a504b2980e
|
GoogleMLKit: 2bd0dc6253c4d4f227aad460f69215a504b2980e
|
||||||
GoogleToolboxForMac: 8bef7c7c5cf7291c687cf5354f39f9db6399ad34
|
GoogleToolboxForMac: 8bef7c7c5cf7291c687cf5354f39f9db6399ad34
|
||||||
@ -160,19 +155,19 @@ SPEC CHECKSUMS:
|
|||||||
MLKitBarcodeScanning: 04e264482c5f3810cb89ebc134ef6b61e67db505
|
MLKitBarcodeScanning: 04e264482c5f3810cb89ebc134ef6b61e67db505
|
||||||
MLKitCommon: c1b791c3e667091918d91bda4bba69a91011e390
|
MLKitCommon: c1b791c3e667091918d91bda4bba69a91011e390
|
||||||
MLKitVision: 8baa5f46ee3352614169b85250574fde38c36f49
|
MLKitVision: 8baa5f46ee3352614169b85250574fde38c36f49
|
||||||
mobile_scanner: 47056db0c04027ea5f41a716385542da28574662
|
mobile_scanner: 38dcd8a49d7d485f632b7de65e4900010187aef2
|
||||||
nanopb: b552cce312b6c8484180ef47159bc0f65a1f0431
|
nanopb: b552cce312b6c8484180ef47159bc0f65a1f0431
|
||||||
pasteboard: 982969ebaa7c78af3e6cc7761e8f5e77565d9ce0
|
pasteboard: 982969ebaa7c78af3e6cc7761e8f5e77565d9ce0
|
||||||
path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943
|
path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c
|
||||||
PromisesObjC: c50d2056b5253dadbd6c2bea79b0674bd5a52fa4
|
PromisesObjC: c50d2056b5253dadbd6c2bea79b0674bd5a52fa4
|
||||||
share_plus: 599aa54e4ea31d4b4c0e9c911bcc26c55e791028
|
share_plus: c3fef564749587fc939ef86ffb283ceac0baf9f5
|
||||||
shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126
|
shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695
|
||||||
smart_auth: 4bedbc118723912d0e45a07e8ab34039c19e04f2
|
smart_auth: 4bedbc118723912d0e45a07e8ab34039c19e04f2
|
||||||
sqflite: 31f7eba61e3074736dff8807a9b41581e4f7f15a
|
sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec
|
||||||
system_info_plus: 5393c8da281d899950d751713575fbf91c7709aa
|
system_info_plus: 5393c8da281d899950d751713575fbf91c7709aa
|
||||||
url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4
|
url_launcher_ios: 6116280ddcfe98ab8820085d8d76ae7449447586
|
||||||
veilid: 51243c25047dbc1ebbfd87d713560260d802b845
|
veilid: f5c2e662f91907b30cf95762619526ac3e4512fd
|
||||||
|
|
||||||
PODFILE CHECKSUM: 7f4cf2154d55730d953b184299e6feee7a274740
|
PODFILE CHECKSUM: 5d504085cd7c7a4d71ee600d7af087cb60ab75b2
|
||||||
|
|
||||||
COCOAPODS: 1.14.2
|
COCOAPODS: 1.15.2
|
||||||
|
@ -155,7 +155,7 @@
|
|||||||
97C146E61CF9000F007C117D /* Project object */ = {
|
97C146E61CF9000F007C117D /* Project object */ = {
|
||||||
isa = PBXProject;
|
isa = PBXProject;
|
||||||
attributes = {
|
attributes = {
|
||||||
LastUpgradeCheck = 1430;
|
LastUpgradeCheck = 1510;
|
||||||
ORGANIZATIONNAME = "";
|
ORGANIZATIONNAME = "";
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
97C146ED1CF9000F007C117D = {
|
97C146ED1CF9000F007C117D = {
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "1430"
|
LastUpgradeVersion = "1510"
|
||||||
version = "1.3">
|
version = "1.3">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
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;
|
T _$identity<T>(T value) => value;
|
||||||
|
|
||||||
final _privateConstructorUsedError = UnsupportedError(
|
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) {
|
LocalAccount _$LocalAccountFromJson(Map<String, dynamic> json) {
|
||||||
return _LocalAccount.fromJson(json);
|
return _LocalAccount.fromJson(json);
|
||||||
@ -225,7 +225,7 @@ class _$LocalAccountImpl implements _LocalAccount {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(dynamic other) {
|
bool operator ==(Object other) {
|
||||||
return identical(this, other) ||
|
return identical(this, other) ||
|
||||||
(other.runtimeType == runtimeType &&
|
(other.runtimeType == runtimeType &&
|
||||||
other is _$LocalAccountImpl &&
|
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 '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.freezed.dart';
|
||||||
part 'user_login.g.dart';
|
part 'user_login.g.dart';
|
||||||
@ -26,21 +25,3 @@ class UserLogin with _$UserLogin {
|
|||||||
factory UserLogin.fromJson(dynamic json) =>
|
factory UserLogin.fromJson(dynamic json) =>
|
||||||
_$UserLoginFromJson(json as Map<String, dynamic>);
|
_$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;
|
T _$identity<T>(T value) => value;
|
||||||
|
|
||||||
final _privateConstructorUsedError = UnsupportedError(
|
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) {
|
UserLogin _$UserLoginFromJson(Map<String, dynamic> json) {
|
||||||
return _UserLogin.fromJson(json);
|
return _UserLogin.fromJson(json);
|
||||||
@ -182,7 +182,7 @@ class _$UserLoginImpl implements _UserLogin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(dynamic other) {
|
bool operator ==(Object other) {
|
||||||
return identical(this, other) ||
|
return identical(this, other) ||
|
||||||
(other.runtimeType == runtimeType &&
|
(other.runtimeType == runtimeType &&
|
||||||
other is _$UserLoginImpl &&
|
other is _$UserLoginImpl &&
|
||||||
@ -238,169 +238,3 @@ abstract class _UserLogin implements UserLogin {
|
|||||||
_$$UserLoginImplCopyWith<_$UserLoginImpl> get copyWith =>
|
_$$UserLoginImplCopyWith<_$UserLoginImpl> get copyWith =>
|
||||||
throw _privateConstructorUsedError;
|
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(),
|
'account_record_info': instance.accountRecordInfo.toJson(),
|
||||||
'last_active': instance.lastActive.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/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_form_builder/flutter_form_builder.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:flutter_translate/flutter_translate.dart';
|
||||||
import 'package:form_builder_validators/form_builder_validators.dart';
|
import 'package:form_builder_validators/form_builder_validators.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../components/default_app_bar.dart';
|
import '../../../layout/default_app_bar.dart';
|
||||||
import '../components/signal_strength_meter.dart';
|
import '../../../tools/tools.dart';
|
||||||
import '../entities/entities.dart';
|
import '../../../veilid_processor/veilid_processor.dart';
|
||||||
import '../providers/local_accounts.dart';
|
import '../../account_manager.dart';
|
||||||
import '../providers/logins.dart';
|
|
||||||
import '../providers/window_control.dart';
|
|
||||||
import '../tools/tools.dart';
|
|
||||||
import '../veilid_support/veilid_support.dart';
|
|
||||||
|
|
||||||
class NewAccountPage extends ConsumerStatefulWidget {
|
class NewAccountPage extends StatefulWidget {
|
||||||
const NewAccountPage({super.key});
|
const NewAccountPage({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
NewAccountPageState createState() => NewAccountPageState();
|
NewAccountPageState createState() => NewAccountPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class NewAccountPageState extends ConsumerState<NewAccountPage> {
|
class NewAccountPageState extends State<NewAccountPage> {
|
||||||
final _formKey = GlobalKey<FormBuilderState>();
|
final _formKey = GlobalKey<FormBuilderState>();
|
||||||
late bool isInAsyncCall = false;
|
late bool isInAsyncCall = false;
|
||||||
static const String formFieldName = 'name';
|
static const String formFieldName = 'name';
|
||||||
@ -34,42 +29,11 @@ class NewAccountPageState extends ConsumerState<NewAccountPage> {
|
|||||||
super.initState();
|
super.initState();
|
||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||||
setState(() {});
|
await changeWindowSetup(
|
||||||
await ref.read(windowControlProvider.notifier).changeWindowSetup(
|
|
||||||
TitleBarStyle.normal, OrientationCapability.portraitOnly);
|
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,
|
Widget _newAccountForm(BuildContext context,
|
||||||
{required Future<void> Function(GlobalKey<FormBuilderState>)
|
{required Future<void> Function(GlobalKey<FormBuilderState>)
|
||||||
onSubmit}) =>
|
onSubmit}) =>
|
||||||
@ -90,12 +54,14 @@ class NewAccountPageState extends ConsumerState<NewAccountPage> {
|
|||||||
validator: FormBuilderValidators.compose([
|
validator: FormBuilderValidators.compose([
|
||||||
FormBuilderValidators.required(),
|
FormBuilderValidators.required(),
|
||||||
]),
|
]),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
),
|
),
|
||||||
FormBuilderTextField(
|
FormBuilderTextField(
|
||||||
name: formFieldPronouns,
|
name: formFieldPronouns,
|
||||||
maxLength: 64,
|
maxLength: 64,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: translate('account.form_pronouns')),
|
labelText: translate('account.form_pronouns')),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
),
|
),
|
||||||
Row(children: [
|
Row(children: [
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
@ -129,13 +95,7 @@ class NewAccountPageState extends ConsumerState<NewAccountPage> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
ref.watch(windowControlProvider);
|
final displayModalHUD = isInAsyncCall;
|
||||||
|
|
||||||
final localAccounts = ref.watch(localAccountsProvider);
|
|
||||||
final logins = ref.watch(loginsProvider);
|
|
||||||
|
|
||||||
final displayModalHUD =
|
|
||||||
isInAsyncCall || !localAccounts.hasValue || !logins.hasValue;
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
// resizeToAvoidBottomInset: false,
|
// resizeToAvoidBottomInset: false,
|
||||||
@ -147,7 +107,7 @@ class NewAccountPageState extends ConsumerState<NewAccountPage> {
|
|||||||
icon: const Icon(Icons.settings),
|
icon: const Icon(Icons.settings),
|
||||||
tooltip: translate('app_bar.settings_tooltip'),
|
tooltip: translate('app_bar.settings_tooltip'),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
context.go('/new_account/settings');
|
await GoRouterHelper(context).push('/settings');
|
||||||
})
|
})
|
||||||
]),
|
]),
|
||||||
body: _newAccountForm(
|
body: _newAccountForm(
|
||||||
@ -155,7 +115,16 @@ class NewAccountPageState extends ConsumerState<NewAccountPage> {
|
|||||||
onSubmit: (formKey) async {
|
onSubmit: (formKey) async {
|
||||||
FocusScope.of(context).unfocus();
|
FocusScope.of(context).unfocus();
|
||||||
try {
|
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) {
|
} on Exception catch (e) {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
await showErrorModal(context, translate('new_account_page.error'),
|
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:animated_theme_switcher/animated_theme_switcher.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
import 'package:flutter_translate/flutter_translate.dart';
|
import 'package:flutter_translate/flutter_translate.dart';
|
||||||
import 'package:form_builder_validators/form_builder_validators.dart';
|
import 'package:form_builder_validators/form_builder_validators.dart';
|
||||||
|
|
||||||
|
import 'account_manager/account_manager.dart';
|
||||||
import 'router/router.dart';
|
import 'router/router.dart';
|
||||||
|
import 'settings/settings.dart';
|
||||||
import 'tick.dart';
|
import 'tick.dart';
|
||||||
|
import 'veilid_processor/veilid_processor.dart';
|
||||||
|
|
||||||
class VeilidChatApp extends ConsumerWidget {
|
class VeilidChatApp extends StatelessWidget {
|
||||||
const VeilidChatApp({
|
const VeilidChatApp({
|
||||||
required this.theme,
|
required this.initialThemeData,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
final ThemeData theme;
|
static const String name = 'VeilidChat';
|
||||||
|
|
||||||
|
final ThemeData initialThemeData;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context) {
|
||||||
final router = ref.watch(routerProvider);
|
|
||||||
final localizationDelegate = LocalizedApp.of(context).delegate;
|
final localizationDelegate = LocalizedApp.of(context).delegate;
|
||||||
|
|
||||||
return ThemeProvider(
|
return ThemeProvider(
|
||||||
initTheme: theme,
|
initTheme: initialThemeData,
|
||||||
builder: (_, theme) => LocalizationProvider(
|
builder: (_, theme) => LocalizationProvider(
|
||||||
state: LocalizationProvider.of(context).state,
|
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(
|
child: BackgroundTicker(
|
||||||
builder: (context) => MaterialApp.router(
|
builder: (context) => MaterialApp.router(
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
routerConfig: router,
|
routerConfig: context.watch<RouterCubit>().router(),
|
||||||
title: translate('app.title'),
|
title: translate('app.title'),
|
||||||
theme: theme,
|
theme: theme,
|
||||||
localizationsDelegates: [
|
localizationsDelegates: [
|
||||||
@ -40,6 +70,7 @@ class VeilidChatApp extends ConsumerWidget {
|
|||||||
],
|
],
|
||||||
supportedLocales: localizationDelegate.supportedLocales,
|
supportedLocales: localizationDelegate.supportedLocales,
|
||||||
locale: localizationDelegate.currentLocale,
|
locale: localizationDelegate.currentLocale,
|
||||||
|
),
|
||||||
)),
|
)),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@ -47,6 +78,7 @@ class VeilidChatApp extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
||||||
super.debugFillProperties(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/foundation.dart';
|
||||||
import 'package:flutter/material.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_slidable/flutter_slidable.dart';
|
||||||
import 'package:flutter_translate/flutter_translate.dart';
|
import 'package:flutter_translate/flutter_translate.dart';
|
||||||
import '../proto/proto.dart' as proto;
|
import '../../chat/cubits/active_chat_cubit.dart';
|
||||||
import '../providers/account.dart';
|
import '../../proto/proto.dart' as proto;
|
||||||
import '../providers/chat.dart';
|
import '../../theme/theme.dart';
|
||||||
import '../tools/theme_service.dart';
|
import '../chat_list.dart';
|
||||||
|
|
||||||
class ChatSingleContactItemWidget extends ConsumerWidget {
|
class ChatSingleContactItemWidget extends StatelessWidget {
|
||||||
const ChatSingleContactItemWidget({required this.contact, super.key});
|
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
|
@override
|
||||||
// ignore: prefer_expression_function_bodies
|
// ignore: prefer_expression_function_bodies
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(
|
||||||
|
BuildContext context,
|
||||||
|
) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
//final textTheme = theme.textTheme;
|
//final textTheme = theme.textTheme;
|
||||||
final scale = theme.extension<ScaleScheme>()!;
|
final scale = theme.extension<ScaleScheme>()!;
|
||||||
|
|
||||||
final activeChat = ref.watch(activeChatStateProvider);
|
final activeChatCubit = context.watch<ActiveChatCubit>();
|
||||||
final remoteConversationRecordKey =
|
final remoteConversationRecordKey =
|
||||||
proto.TypedKeyProto.fromProto(contact.remoteConversationRecordKey);
|
_contact.remoteConversationRecordKey.toVeilid();
|
||||||
final selected = activeChat == remoteConversationRecordKey;
|
final selected = activeChatCubit.state == remoteConversationRecordKey;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.fromLTRB(0, 4, 0, 0),
|
margin: const EdgeInsets.fromLTRB(0, 4, 0, 0),
|
||||||
@ -34,21 +43,18 @@ class ChatSingleContactItemWidget extends ConsumerWidget {
|
|||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
)),
|
)),
|
||||||
child: Slidable(
|
child: Slidable(
|
||||||
key: ObjectKey(contact),
|
key: ObjectKey(_contact),
|
||||||
endActionPane: ActionPane(
|
endActionPane: ActionPane(
|
||||||
motion: const DrawerMotion(),
|
motion: const DrawerMotion(),
|
||||||
children: [
|
children: [
|
||||||
SlidableAction(
|
SlidableAction(
|
||||||
onPressed: (context) async {
|
onPressed: _disabled
|
||||||
final activeAccountInfo =
|
? null
|
||||||
await ref.read(fetchActiveAccountProvider.future);
|
: (context) async {
|
||||||
if (activeAccountInfo != null) {
|
final chatListCubit = context.read<ChatListCubit>();
|
||||||
await deleteChat(
|
await chatListCubit.deleteChat(
|
||||||
activeAccountInfo: activeAccountInfo,
|
|
||||||
remoteConversationRecordKey:
|
remoteConversationRecordKey:
|
||||||
remoteConversationRecordKey);
|
remoteConversationRecordKey);
|
||||||
ref.invalidate(fetchChatListProvider);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
backgroundColor: scale.tertiaryScale.background,
|
backgroundColor: scale.tertiaryScale.background,
|
||||||
foregroundColor: scale.tertiaryScale.text,
|
foregroundColor: scale.tertiaryScale.text,
|
||||||
@ -68,16 +74,19 @@ class ChatSingleContactItemWidget extends ConsumerWidget {
|
|||||||
// The child of the Slidable is what the user sees when the
|
// The child of the Slidable is what the user sees when the
|
||||||
// component is not dragged.
|
// component is not dragged.
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
onTap: () async {
|
onTap: _disabled
|
||||||
ref.read(activeChatStateProvider.notifier).state =
|
? null
|
||||||
remoteConversationRecordKey;
|
: () {
|
||||||
ref.invalidate(fetchChatListProvider);
|
singleFuture(activeChatCubit, () async {
|
||||||
|
activeChatCubit
|
||||||
|
.setActiveChat(remoteConversationRecordKey);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
title: Text(contact.editedProfile.name),
|
title: Text(_contact.editedProfile.name),
|
||||||
|
|
||||||
/// xxx show last message here
|
/// xxx show last message here
|
||||||
subtitle: (contact.editedProfile.pronouns.isNotEmpty)
|
subtitle: (_contact.editedProfile.pronouns.isNotEmpty)
|
||||||
? Text(contact.editedProfile.pronouns)
|
? Text(_contact.editedProfile.pronouns)
|
||||||
: null,
|
: null,
|
||||||
iconColor: scale.tertiaryScale.background,
|
iconColor: scale.tertiaryScale.background,
|
||||||
textColor: scale.tertiaryScale.text,
|
textColor: scale.tertiaryScale.text,
|
||||||
@ -89,6 +98,6 @@ class ChatSingleContactItemWidget extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
||||||
super.debugFillProperties(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/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
import 'package:flutter_translate/flutter_translate.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});
|
const EmptyChatListWidget({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
// ignore: prefer_expression_function_bodies
|
// ignore: prefer_expression_function_bodies
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final textTheme = theme.textTheme;
|
final textTheme = theme.textTheme;
|
||||||
final scale = theme.extension<ScaleScheme>()!;
|
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/foundation.dart';
|
||||||
import 'package:flutter/material.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_slidable/flutter_slidable.dart';
|
||||||
import 'package:flutter_translate/flutter_translate.dart';
|
import 'package:flutter_translate/flutter_translate.dart';
|
||||||
import '../proto/proto.dart' as proto;
|
import '../../proto/proto.dart' as proto;
|
||||||
import '../providers/account.dart';
|
import '../../theme/theme.dart';
|
||||||
import '../providers/contact_invite.dart';
|
import '../contact_invitation.dart';
|
||||||
import '../tools/tools.dart';
|
|
||||||
import 'contact_invitation_display.dart';
|
|
||||||
|
|
||||||
class ContactInvitationItemWidget extends ConsumerWidget {
|
class ContactInvitationItemWidget extends StatelessWidget {
|
||||||
const ContactInvitationItemWidget(
|
const ContactInvitationItemWidget(
|
||||||
{required this.contactInvitationRecord, super.key});
|
{required this.contactInvitationRecord,
|
||||||
|
required this.disabled,
|
||||||
|
super.key});
|
||||||
|
|
||||||
final proto.ContactInvitationRecord contactInvitationRecord;
|
final proto.ContactInvitationRecord contactInvitationRecord;
|
||||||
|
final bool disabled;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
||||||
super.debugFillProperties(properties);
|
super.debugFillProperties(properties);
|
||||||
properties.add(DiagnosticsProperty<proto.ContactInvitationRecord>(
|
properties
|
||||||
'contactInvitationRecord', contactInvitationRecord));
|
..add(DiagnosticsProperty<proto.ContactInvitationRecord>(
|
||||||
|
'contactInvitationRecord', contactInvitationRecord))
|
||||||
|
..add(DiagnosticsProperty<bool>('disabled', disabled));
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
// ignore: prefer_expression_function_bodies
|
// ignore: prefer_expression_function_bodies
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
//final textTheme = theme.textTheme;
|
//final textTheme = theme.textTheme;
|
||||||
final scale = theme.extension<ScaleScheme>()!;
|
final scale = theme.extension<ScaleScheme>()!;
|
||||||
@ -50,16 +53,17 @@ class ContactInvitationItemWidget extends ConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
// A SlidableAction can have an icon and/or a label.
|
// A SlidableAction can have an icon and/or a label.
|
||||||
SlidableAction(
|
SlidableAction(
|
||||||
onPressed: (context) async {
|
onPressed: disabled
|
||||||
final activeAccountInfo =
|
? null
|
||||||
await ref.read(fetchActiveAccountProvider.future);
|
: (context) async {
|
||||||
if (activeAccountInfo != null) {
|
final contactInvitationListCubit =
|
||||||
await deleteContactInvitation(
|
context.read<ContactInvitationListCubit>();
|
||||||
|
await contactInvitationListCubit.deleteInvitation(
|
||||||
accepted: false,
|
accepted: false,
|
||||||
activeAccountInfo: activeAccountInfo,
|
contactRequestInboxRecordKey:
|
||||||
contactInvitationRecord: contactInvitationRecord);
|
contactInvitationRecord
|
||||||
ref.invalidate(fetchContactInvitationRecordsProvider);
|
.contactRequestInbox.recordKey
|
||||||
}
|
.toVeilid());
|
||||||
},
|
},
|
||||||
backgroundColor: scale.tertiaryScale.background,
|
backgroundColor: scale.tertiaryScale.background,
|
||||||
foregroundColor: scale.tertiaryScale.text,
|
foregroundColor: scale.tertiaryScale.text,
|
||||||
@ -95,23 +99,21 @@ class ContactInvitationItemWidget extends ConsumerWidget {
|
|||||||
// component is not dragged.
|
// component is not dragged.
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
//title: Text(translate('contact_list.invitation')),
|
//title: Text(translate('contact_list.invitation')),
|
||||||
onTap: () async {
|
onTap: disabled
|
||||||
final activeAccountInfo =
|
? null
|
||||||
await ref.read(fetchActiveAccountProvider.future);
|
: () async {
|
||||||
if (activeAccountInfo != null) {
|
|
||||||
// ignore: use_build_context_synchronously
|
|
||||||
if (!context.mounted) {
|
if (!context.mounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await showDialog<void>(
|
await showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => ContactInvitationDisplayDialog(
|
builder: (context) => BlocProvider(
|
||||||
name: activeAccountInfo.localAccount.name,
|
create: (context) => InvitationGeneratorCubit
|
||||||
|
.value(Uint8List.fromList(
|
||||||
|
contactInvitationRecord.invitation)),
|
||||||
|
child: ContactInvitationDisplayDialog(
|
||||||
message: contactInvitationRecord.message,
|
message: contactInvitationRecord.message,
|
||||||
generator: Uint8List.fromList(
|
)));
|
||||||
contactInvitationRecord.invitation),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
title: Text(
|
title: Text(
|
||||||
contactInvitationRecord.message.isEmpty
|
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:fast_immutable_collections/fast_immutable_collections.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
|
|
||||||
import '../proto/proto.dart' as proto;
|
import '../../proto/proto.dart' as proto;
|
||||||
import '../tools/tools.dart';
|
import '../../theme/theme.dart';
|
||||||
import 'contact_invitation_item_widget.dart';
|
import 'contact_invitation_item_widget.dart';
|
||||||
|
|
||||||
class ContactInvitationListWidget extends ConsumerStatefulWidget {
|
class ContactInvitationListWidget extends StatefulWidget {
|
||||||
const ContactInvitationListWidget({
|
const ContactInvitationListWidget({
|
||||||
required this.contactInvitationRecordList,
|
required this.contactInvitationRecordList,
|
||||||
|
required this.disabled,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
final IList<proto.ContactInvitationRecord> contactInvitationRecordList;
|
final IList<proto.ContactInvitationRecord> contactInvitationRecordList;
|
||||||
|
final bool disabled;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ContactInvitationListWidgetState createState() =>
|
ContactInvitationListWidgetState createState() =>
|
||||||
@ -22,13 +23,15 @@ class ContactInvitationListWidget extends ConsumerStatefulWidget {
|
|||||||
@override
|
@override
|
||||||
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
||||||
super.debugFillProperties(properties);
|
super.debugFillProperties(properties);
|
||||||
properties.add(IterableProperty<proto.ContactInvitationRecord>(
|
properties
|
||||||
'contactInvitationRecordList', contactInvitationRecordList));
|
..add(IterableProperty<proto.ContactInvitationRecord>(
|
||||||
|
'contactInvitationRecordList', contactInvitationRecordList))
|
||||||
|
..add(DiagnosticsProperty<bool>('disabled', disabled));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class ContactInvitationListWidgetState
|
class ContactInvitationListWidgetState
|
||||||
extends ConsumerState<ContactInvitationListWidget> {
|
extends State<ContactInvitationListWidget> {
|
||||||
final ScrollController _scrollController = ScrollController();
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -64,6 +67,7 @@ class ContactInvitationListWidgetState
|
|||||||
return ContactInvitationItemWidget(
|
return ContactInvitationItemWidget(
|
||||||
contactInvitationRecord:
|
contactInvitationRecord:
|
||||||
widget.contactInvitationRecordList[index],
|
widget.contactInvitationRecordList[index],
|
||||||
|
disabled: widget.disabled,
|
||||||
key: ObjectKey(widget.contactInvitationRecordList[index]))
|
key: ObjectKey(widget.contactInvitationRecordList[index]))
|
||||||
.paddingLTRB(4, 2, 4, 2);
|
.paddingLTRB(4, 2, 4, 2);
|
||||||
},
|
},
|
@ -3,21 +3,18 @@ import 'dart:async';
|
|||||||
import 'package:awesome_extensions/awesome_extensions.dart';
|
import 'package:awesome_extensions/awesome_extensions.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.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 'package:flutter_translate/flutter_translate.dart';
|
||||||
|
|
||||||
import '../entities/local_account.dart';
|
import '../../account_manager/account_manager.dart';
|
||||||
import '../providers/account.dart';
|
import '../../contacts/contacts.dart';
|
||||||
import '../providers/contact.dart';
|
import '../../tools/tools.dart';
|
||||||
import '../providers/contact_invite.dart';
|
import '../contact_invitation.dart';
|
||||||
import '../tools/tools.dart';
|
|
||||||
import 'enter_password.dart';
|
|
||||||
import 'enter_pin.dart';
|
|
||||||
import 'profile_widget.dart';
|
|
||||||
|
|
||||||
class InviteDialog extends ConsumerStatefulWidget {
|
class InviteDialog extends StatefulWidget {
|
||||||
const InviteDialog(
|
const InviteDialog(
|
||||||
{required this.onValidationCancelled,
|
{required this.modalContext,
|
||||||
|
required this.onValidationCancelled,
|
||||||
required this.onValidationSuccess,
|
required this.onValidationSuccess,
|
||||||
required this.onValidationFailed,
|
required this.onValidationFailed,
|
||||||
required this.inviteControlIsValid,
|
required this.inviteControlIsValid,
|
||||||
@ -33,6 +30,7 @@ class InviteDialog extends ConsumerStatefulWidget {
|
|||||||
InviteDialogState dialogState,
|
InviteDialogState dialogState,
|
||||||
Future<void> Function({required Uint8List inviteData})
|
Future<void> Function({required Uint8List inviteData})
|
||||||
validateInviteData) buildInviteControl;
|
validateInviteData) buildInviteControl;
|
||||||
|
final BuildContext modalContext;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
InviteDialogState createState() => InviteDialogState();
|
InviteDialogState createState() => InviteDialogState();
|
||||||
@ -54,11 +52,12 @@ class InviteDialog extends ConsumerStatefulWidget {
|
|||||||
InviteDialogState dialogState,
|
InviteDialogState dialogState,
|
||||||
Future<void> Function({required Uint8List inviteData})
|
Future<void> Function({required Uint8List inviteData})
|
||||||
validateInviteData)>.has(
|
validateInviteData)>.has(
|
||||||
'buildInviteControl', buildInviteControl));
|
'buildInviteControl', buildInviteControl))
|
||||||
|
..add(DiagnosticsProperty<BuildContext>('modalContext', modalContext));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class InviteDialogState extends ConsumerState<InviteDialog> {
|
class InviteDialogState extends State<InviteDialog> {
|
||||||
ValidContactInvitation? _validInvitation;
|
ValidContactInvitation? _validInvitation;
|
||||||
bool _isValidating = false;
|
bool _isValidating = false;
|
||||||
bool _isAccepting = false;
|
bool _isAccepting = false;
|
||||||
@ -73,22 +72,15 @@ class InviteDialogState extends ConsumerState<InviteDialog> {
|
|||||||
|
|
||||||
Future<void> _onAccept() async {
|
Future<void> _onAccept() async {
|
||||||
final navigator = Navigator.of(context);
|
final navigator = Navigator.of(context);
|
||||||
|
final activeAccountInfo = widget.modalContext.read<ActiveAccountInfo>();
|
||||||
|
final contactList = widget.modalContext.read<ContactListCubit>();
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_isAccepting = true;
|
_isAccepting = true;
|
||||||
});
|
});
|
||||||
final activeAccountInfo = await ref.read(fetchActiveAccountProvider.future);
|
|
||||||
if (activeAccountInfo == null) {
|
|
||||||
setState(() {
|
|
||||||
_isAccepting = false;
|
|
||||||
});
|
|
||||||
navigator.pop();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final validInvitation = _validInvitation;
|
final validInvitation = _validInvitation;
|
||||||
if (validInvitation != null) {
|
if (validInvitation != null) {
|
||||||
final acceptedContact =
|
final acceptedContact = await validInvitation.accept();
|
||||||
await acceptContactInvitation(activeAccountInfo, validInvitation);
|
|
||||||
if (acceptedContact != null) {
|
if (acceptedContact != null) {
|
||||||
// initiator when accept is received will create
|
// initiator when accept is received will create
|
||||||
// contact in the case of a 'note to self'
|
// contact in the case of a 'note to self'
|
||||||
@ -96,9 +88,8 @@ class InviteDialogState extends ConsumerState<InviteDialog> {
|
|||||||
activeAccountInfo.localAccount.identityMaster.identityPublicKey ==
|
activeAccountInfo.localAccount.identityMaster.identityPublicKey ==
|
||||||
acceptedContact.remoteIdentity.identityPublicKey;
|
acceptedContact.remoteIdentity.identityPublicKey;
|
||||||
if (!isSelf) {
|
if (!isSelf) {
|
||||||
await createContact(
|
await contactList.createContact(
|
||||||
activeAccountInfo: activeAccountInfo,
|
remoteProfile: acceptedContact.remoteProfile,
|
||||||
profile: acceptedContact.profile,
|
|
||||||
remoteIdentity: acceptedContact.remoteIdentity,
|
remoteIdentity: acceptedContact.remoteIdentity,
|
||||||
remoteConversationRecordKey:
|
remoteConversationRecordKey:
|
||||||
acceptedContact.remoteConversationRecordKey,
|
acceptedContact.remoteConversationRecordKey,
|
||||||
@ -106,9 +97,6 @@ class InviteDialogState extends ConsumerState<InviteDialog> {
|
|||||||
acceptedContact.localConversationRecordKey,
|
acceptedContact.localConversationRecordKey,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
ref
|
|
||||||
..invalidate(fetchContactInvitationRecordsProvider)
|
|
||||||
..invalidate(fetchContactListProvider);
|
|
||||||
} else {
|
} else {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
showErrorToast(context, 'invite_dialog.failed_to_accept');
|
showErrorToast(context, 'invite_dialog.failed_to_accept');
|
||||||
@ -127,17 +115,9 @@ class InviteDialogState extends ConsumerState<InviteDialog> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_isAccepting = true;
|
_isAccepting = true;
|
||||||
});
|
});
|
||||||
final activeAccountInfo = await ref.read(fetchActiveAccountProvider.future);
|
|
||||||
if (activeAccountInfo == null) {
|
|
||||||
setState(() {
|
|
||||||
_isAccepting = false;
|
|
||||||
});
|
|
||||||
navigator.pop();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final validInvitation = _validInvitation;
|
final validInvitation = _validInvitation;
|
||||||
if (validInvitation != null) {
|
if (validInvitation != null) {
|
||||||
if (await rejectContactInvitation(activeAccountInfo, validInvitation)) {
|
if (await validInvitation.reject()) {
|
||||||
// do nothing right now
|
// do nothing right now
|
||||||
} else {
|
} else {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
@ -155,25 +135,15 @@ class InviteDialogState extends ConsumerState<InviteDialog> {
|
|||||||
required Uint8List inviteData,
|
required Uint8List inviteData,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final activeAccountInfo =
|
final contactInvitationListCubit =
|
||||||
await ref.read(fetchActiveAccountProvider.future);
|
widget.modalContext.read<ContactInvitationListCubit>();
|
||||||
if (activeAccountInfo == null) {
|
|
||||||
setState(() {
|
|
||||||
_isValidating = false;
|
|
||||||
_validInvitation = null;
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final contactInvitationRecords =
|
|
||||||
await ref.read(fetchContactInvitationRecordsProvider.future);
|
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_isValidating = true;
|
_isValidating = true;
|
||||||
_validInvitation = null;
|
_validInvitation = null;
|
||||||
});
|
});
|
||||||
final validatedContactInvitation = await validateContactInvitation(
|
final validatedContactInvitation =
|
||||||
activeAccountInfo: activeAccountInfo,
|
await contactInvitationListCubit.validateInvitation(
|
||||||
contactInvitationRecords: contactInvitationRecords,
|
|
||||||
inviteData: inviteData,
|
inviteData: inviteData,
|
||||||
getEncryptionKeyCallback:
|
getEncryptionKeyCallback:
|
||||||
(cs, encryptionKeyType, encryptedSecret) async {
|
(cs, encryptionKeyType, encryptedSecret) async {
|
||||||
@ -210,10 +180,9 @@ class InviteDialogState extends ConsumerState<InviteDialog> {
|
|||||||
}
|
}
|
||||||
encryptionKey = password;
|
encryptionKey = password;
|
||||||
}
|
}
|
||||||
return decryptSecretFromBytes(
|
return encryptionKeyType.decryptSecretFromBytes(
|
||||||
secretBytes: encryptedSecret,
|
secretBytes: encryptedSecret,
|
||||||
cryptoKind: cs.kind(),
|
cryptoKind: cs.kind(),
|
||||||
encryptionKeyType: encryptionKeyType,
|
|
||||||
encryptionKey: encryptionKey);
|
encryptionKey: encryptionKey);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -276,7 +245,7 @@ class InviteDialogState extends ConsumerState<InviteDialog> {
|
|||||||
return SizedBox(
|
return SizedBox(
|
||||||
height: 300,
|
height: 300,
|
||||||
width: 300,
|
width: 300,
|
||||||
child: buildProgressIndicator(context).toCenter())
|
child: buildProgressIndicator().toCenter())
|
||||||
.paddingAll(16);
|
.paddingAll(16);
|
||||||
}
|
}
|
||||||
return ConstrainedBox(
|
return ConstrainedBox(
|
||||||
@ -292,7 +261,7 @@ class InviteDialogState extends ConsumerState<InviteDialog> {
|
|||||||
Column(children: [
|
Column(children: [
|
||||||
Text(translate('invite_dialog.validating'))
|
Text(translate('invite_dialog.validating'))
|
||||||
.paddingLTRB(0, 0, 0, 16),
|
.paddingLTRB(0, 0, 0, 16),
|
||||||
buildProgressIndicator(context).paddingAll(16),
|
buildProgressIndicator().paddingAll(16),
|
||||||
]).toCenter(),
|
]).toCenter(),
|
||||||
if (_validInvitation == null &&
|
if (_validInvitation == null &&
|
||||||
!_isValidating &&
|
!_isValidating &&
|
||||||
@ -307,11 +276,8 @@ class InviteDialogState extends ConsumerState<InviteDialog> {
|
|||||||
constraints: const BoxConstraints(maxHeight: 64),
|
constraints: const BoxConstraints(maxHeight: 64),
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: ProfileWidget(
|
child: ProfileWidget(
|
||||||
name: _validInvitation!
|
profile: _validInvitation!.remoteProfile))
|
||||||
.contactRequestPrivate.profile.name,
|
.paddingLTRB(0, 0, 0, 8),
|
||||||
pronouns: _validInvitation!
|
|
||||||
.contactRequestPrivate.profile.pronouns,
|
|
||||||
)).paddingLTRB(0, 0, 0, 8),
|
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
children: [
|
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 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:awesome_extensions/awesome_extensions.dart';
|
import 'package:awesome_extensions/awesome_extensions.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
import 'package:flutter_translate/flutter_translate.dart';
|
import 'package:flutter_translate/flutter_translate.dart';
|
||||||
|
import 'package:veilid_support/veilid_support.dart';
|
||||||
|
|
||||||
import '../tools/tools.dart';
|
import '../../theme/theme.dart';
|
||||||
import '../veilid_support/veilid_support.dart';
|
import '../../tools/tools.dart';
|
||||||
import 'invite_dialog.dart';
|
import 'invite_dialog.dart';
|
||||||
|
|
||||||
class PasteInviteDialog extends ConsumerStatefulWidget {
|
class PasteInviteDialog extends StatefulWidget {
|
||||||
const PasteInviteDialog({super.key});
|
const PasteInviteDialog({required this.modalContext, super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
PasteInviteDialogState createState() => PasteInviteDialogState();
|
PasteInviteDialogState createState() => PasteInviteDialogState();
|
||||||
@ -20,11 +21,20 @@ class PasteInviteDialog extends ConsumerStatefulWidget {
|
|||||||
await showStyledDialog<void>(
|
await showStyledDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
title: translate('paste_invite_dialog.title'),
|
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();
|
final _pasteTextController = TextEditingController();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -122,6 +132,7 @@ class PasteInviteDialogState extends ConsumerState<PasteInviteDialog> {
|
|||||||
// ignore: prefer_expression_function_bodies
|
// ignore: prefer_expression_function_bodies
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return InviteDialog(
|
return InviteDialog(
|
||||||
|
modalContext: widget.modalContext,
|
||||||
onValidationCancelled: onValidationCancelled,
|
onValidationCancelled: onValidationCancelled,
|
||||||
onValidationSuccess: onValidationSuccess,
|
onValidationSuccess: onValidationSuccess,
|
||||||
onValidationFailed: onValidationFailed,
|
onValidationFailed: onValidationFailed,
|
@ -6,14 +6,14 @@ import 'package:awesome_extensions/awesome_extensions.dart';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/scheduler.dart';
|
import 'package:flutter/scheduler.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
import 'package:flutter_translate/flutter_translate.dart';
|
import 'package:flutter_translate/flutter_translate.dart';
|
||||||
import 'package:image/image.dart' as img;
|
import 'package:image/image.dart' as img;
|
||||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||||
import 'package:pasteboard/pasteboard.dart';
|
import 'package:pasteboard/pasteboard.dart';
|
||||||
import 'package:zxing2/qrcode.dart';
|
import 'package:zxing2/qrcode.dart';
|
||||||
|
|
||||||
import '../tools/tools.dart';
|
import '../../theme/theme.dart';
|
||||||
|
import '../../tools/tools.dart';
|
||||||
import 'invite_dialog.dart';
|
import 'invite_dialog.dart';
|
||||||
|
|
||||||
class BarcodeOverlay extends CustomPainter {
|
class BarcodeOverlay extends CustomPainter {
|
||||||
@ -31,9 +31,6 @@ class BarcodeOverlay extends CustomPainter {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void paint(Canvas canvas, Size size) {
|
void paint(Canvas canvas, Size size) {
|
||||||
if (barcode.corners == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final adjustedSize = applyBoxFit(boxFit, arguments.size, size);
|
final adjustedSize = applyBoxFit(boxFit, arguments.size, size);
|
||||||
|
|
||||||
var verticalPadding = size.height - adjustedSize.destination.height;
|
var verticalPadding = size.height - adjustedSize.destination.height;
|
||||||
@ -50,15 +47,14 @@ class BarcodeOverlay extends CustomPainter {
|
|||||||
horizontalPadding = 0;
|
horizontalPadding = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
final ratioWidth =
|
final ratioWidth = (Platform.isIOS ? capture.width : arguments.size.width) /
|
||||||
(Platform.isIOS ? capture.width! : arguments.size.width) /
|
|
||||||
adjustedSize.destination.width;
|
adjustedSize.destination.width;
|
||||||
final ratioHeight =
|
final ratioHeight =
|
||||||
(Platform.isIOS ? capture.height! : arguments.size.height) /
|
(Platform.isIOS ? capture.height : arguments.size.height) /
|
||||||
adjustedSize.destination.height;
|
adjustedSize.destination.height;
|
||||||
|
|
||||||
final adjustedOffset = <Offset>[];
|
final adjustedOffset = <Offset>[];
|
||||||
for (final offset in barcode.corners!) {
|
for (final offset in barcode.corners) {
|
||||||
adjustedOffset.add(
|
adjustedOffset.add(
|
||||||
Offset(
|
Offset(
|
||||||
offset.dx / ratioWidth + horizontalPadding,
|
offset.dx / ratioWidth + horizontalPadding,
|
||||||
@ -107,8 +103,8 @@ class ScannerOverlay extends CustomPainter {
|
|||||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||||
}
|
}
|
||||||
|
|
||||||
class ScanInviteDialog extends ConsumerStatefulWidget {
|
class ScanInviteDialog extends StatefulWidget {
|
||||||
const ScanInviteDialog({super.key});
|
const ScanInviteDialog({required this.modalContext, super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ScanInviteDialogState createState() => ScanInviteDialogState();
|
ScanInviteDialogState createState() => ScanInviteDialogState();
|
||||||
@ -117,11 +113,20 @@ class ScanInviteDialog extends ConsumerStatefulWidget {
|
|||||||
await showStyledDialog<void>(
|
await showStyledDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
title: translate('scan_invite_dialog.title'),
|
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;
|
bool scanned = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -384,6 +389,7 @@ class ScanInviteDialogState extends ConsumerState<ScanInviteDialog> {
|
|||||||
// ignore: prefer_expression_function_bodies
|
// ignore: prefer_expression_function_bodies
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return InviteDialog(
|
return InviteDialog(
|
||||||
|
modalContext: widget.modalContext,
|
||||||
onValidationCancelled: onValidationCancelled,
|
onValidationCancelled: onValidationCancelled,
|
||||||
onValidationSuccess: onValidationSuccess,
|
onValidationSuccess: onValidationSuccess,
|
||||||
onValidationFailed: onValidationFailed,
|
onValidationFailed: onValidationFailed,
|
@ -5,20 +5,16 @@ import 'package:awesome_extensions/awesome_extensions.dart';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.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:flutter_translate/flutter_translate.dart';
|
||||||
|
import 'package:veilid_support/veilid_support.dart';
|
||||||
|
|
||||||
import '../entities/local_account.dart';
|
import '../../account_manager/account_manager.dart';
|
||||||
import '../providers/account.dart';
|
import '../../tools/tools.dart';
|
||||||
import '../providers/contact_invite.dart';
|
import '../contact_invitation.dart';
|
||||||
import '../tools/tools.dart';
|
|
||||||
import '../veilid_support/veilid_support.dart';
|
|
||||||
import 'contact_invitation_display.dart';
|
|
||||||
import 'enter_password.dart';
|
|
||||||
import 'enter_pin.dart';
|
|
||||||
|
|
||||||
class SendInviteDialog extends ConsumerStatefulWidget {
|
class SendInviteDialog extends StatefulWidget {
|
||||||
const SendInviteDialog({super.key});
|
const SendInviteDialog({required this.modalContext, super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
SendInviteDialogState createState() => SendInviteDialogState();
|
SendInviteDialogState createState() => SendInviteDialogState();
|
||||||
@ -27,11 +23,20 @@ class SendInviteDialog extends ConsumerStatefulWidget {
|
|||||||
await showStyledDialog<void>(
|
await showStyledDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
title: translate('send_invite_dialog.title'),
|
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(
|
final _messageTextController = TextEditingController(
|
||||||
text: translate('send_invite_dialog.connect_with_me'));
|
text: translate('send_invite_dialog.connect_with_me'));
|
||||||
|
|
||||||
@ -61,8 +66,7 @@ class SendInviteDialogState extends ConsumerState<SendInviteDialog> {
|
|||||||
if (pin == null) {
|
if (pin == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// ignore: use_build_context_synchronously
|
if (!mounted) {
|
||||||
if (!context.mounted) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final matchpin = await showDialog<String>(
|
final matchpin = await showDialog<String>(
|
||||||
@ -79,8 +83,7 @@ class SendInviteDialogState extends ConsumerState<SendInviteDialog> {
|
|||||||
_encryptionKey = pin;
|
_encryptionKey = pin;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// ignore: use_build_context_synchronously
|
if (!mounted) {
|
||||||
if (!context.mounted) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
showErrorToast(
|
showErrorToast(
|
||||||
@ -100,8 +103,7 @@ class SendInviteDialogState extends ConsumerState<SendInviteDialog> {
|
|||||||
if (password == null) {
|
if (password == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// ignore: use_build_context_synchronously
|
if (!mounted) {
|
||||||
if (!context.mounted) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final matchpass = await showDialog<String>(
|
final matchpass = await showDialog<String>(
|
||||||
@ -118,8 +120,7 @@ class SendInviteDialogState extends ConsumerState<SendInviteDialog> {
|
|||||||
_encryptionKey = password;
|
_encryptionKey = password;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// ignore: use_build_context_synchronously
|
if (!mounted) {
|
||||||
if (!context.mounted) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
showErrorToast(
|
showErrorToast(
|
||||||
@ -135,32 +136,23 @@ class SendInviteDialogState extends ConsumerState<SendInviteDialog> {
|
|||||||
final navigator = Navigator.of(context);
|
final navigator = Navigator.of(context);
|
||||||
|
|
||||||
// Start generation
|
// Start generation
|
||||||
final activeAccountInfo = await ref.read(fetchActiveAccountProvider.future);
|
final contactInvitationListCubit =
|
||||||
if (activeAccountInfo == null) {
|
widget.modalContext.read<ContactInvitationListCubit>();
|
||||||
navigator.pop();
|
|
||||||
return;
|
final generator = contactInvitationListCubit.createInvitation(
|
||||||
}
|
|
||||||
final generator = createContactInvitation(
|
|
||||||
activeAccountInfo: activeAccountInfo,
|
|
||||||
encryptionKeyType: _encryptionKeyType,
|
encryptionKeyType: _encryptionKeyType,
|
||||||
encryptionKey: _encryptionKey,
|
encryptionKey: _encryptionKey,
|
||||||
message: _messageTextController.text,
|
message: _messageTextController.text,
|
||||||
expiration: _expiration);
|
expiration: _expiration);
|
||||||
// ignore: use_build_context_synchronously
|
|
||||||
if (!context.mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await showDialog<void>(
|
await showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => ContactInvitationDisplayDialog(
|
builder: (context) => BlocProvider(
|
||||||
name: activeAccountInfo.localAccount.name,
|
create: (context) => InvitationGeneratorCubit(generator),
|
||||||
|
child: ContactInvitationDisplayDialog(
|
||||||
message: _messageTextController.text,
|
message: _messageTextController.text,
|
||||||
generator: generator,
|
)));
|
||||||
));
|
|
||||||
// if (ret == null) {
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
ref.invalidate(fetchContactInvitationRecordsProvider);
|
|
||||||
navigator.pop();
|
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:fast_immutable_collections/fast_immutable_collections.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
import 'package:flutter_translate/flutter_translate.dart';
|
import 'package:flutter_translate/flutter_translate.dart';
|
||||||
import 'package:searchable_listview/searchable_listview.dart';
|
import 'package:searchable_listview/searchable_listview.dart';
|
||||||
|
|
||||||
import '../proto/proto.dart' as proto;
|
import '../../proto/proto.dart' as proto;
|
||||||
import '../tools/tools.dart';
|
import '../../theme/theme.dart';
|
||||||
|
import '../../tools/tools.dart';
|
||||||
import 'contact_item_widget.dart';
|
import 'contact_item_widget.dart';
|
||||||
import 'empty_contact_list_widget.dart';
|
import 'empty_contact_list_widget.dart';
|
||||||
|
|
||||||
class ContactListWidget extends ConsumerWidget {
|
class ContactListWidget extends StatelessWidget {
|
||||||
const ContactListWidget({required this.contactList, super.key});
|
const ContactListWidget(
|
||||||
|
{required this.contactList, required this.disabled, super.key});
|
||||||
final IList<proto.Contact> contactList;
|
final IList<proto.Contact> contactList;
|
||||||
|
final bool disabled;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
||||||
super.debugFillProperties(properties);
|
super.debugFillProperties(properties);
|
||||||
properties.add(IterableProperty<proto.Contact>('contactList', contactList));
|
properties
|
||||||
|
..add(IterableProperty<proto.Contact>('contactList', contactList))
|
||||||
|
..add(DiagnosticsProperty<bool>('disabled', disabled));
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
//final textTheme = theme.textTheme;
|
//final textTheme = theme.textTheme;
|
||||||
final scale = theme.extension<ScaleScheme>()!;
|
final scale = theme.extension<ScaleScheme>()!;
|
||||||
@ -35,9 +39,9 @@ class ContactListWidget extends ConsumerWidget {
|
|||||||
child: (contactList.isEmpty)
|
child: (contactList.isEmpty)
|
||||||
? const EmptyContactListWidget()
|
? const EmptyContactListWidget()
|
||||||
: SearchableList<proto.Contact>(
|
: SearchableList<proto.Contact>(
|
||||||
autoFocusOnSearch: false,
|
|
||||||
initialList: contactList.toList(),
|
initialList: contactList.toList(),
|
||||||
builder: (l, i, c) => ContactItemWidget(contact: c),
|
builder: (l, i, c) =>
|
||||||
|
ContactItemWidget(contact: c, disabled: disabled),
|
||||||
filter: (value) {
|
filter: (value) {
|
||||||
final lowerValue = value.toLowerCase();
|
final lowerValue = value.toLowerCase();
|
||||||
return contactList
|
return contactList
|
@ -1,15 +1,16 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
import 'package:flutter_translate/flutter_translate.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});
|
const EmptyContactListWidget({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
// ignore: prefer_expression_function_bodies
|
// ignore: prefer_expression_function_bodies
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(
|
||||||
|
BuildContext context,
|
||||||
|
) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final textTheme = theme.textTheme;
|
final textTheme = theme.textTheme;
|
||||||
final scale = theme.extension<ScaleScheme>()!;
|
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:awesome_extensions/awesome_extensions.dart';
|
||||||
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
|
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter/material.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 'package:flutter_translate/flutter_translate.dart';
|
||||||
|
|
||||||
import '../../components/contact_invitation_list_widget.dart';
|
import '../../../../contact_invitation/contact_invitation.dart';
|
||||||
import '../../components/contact_list_widget.dart';
|
import '../../../../contacts/contacts.dart';
|
||||||
import '../../entities/local_account.dart';
|
import '../../../../theme/theme.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';
|
|
||||||
|
|
||||||
class AccountPage extends ConsumerStatefulWidget {
|
class AccountPage extends StatefulWidget {
|
||||||
const AccountPage({
|
const AccountPage({
|
||||||
required this.localAccounts,
|
|
||||||
required this.activeUserLogin,
|
|
||||||
required this.account,
|
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
final IList<LocalAccount> localAccounts;
|
|
||||||
final TypedKey activeUserLogin;
|
|
||||||
final proto.Account account;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
AccountPageState createState() => AccountPageState();
|
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();
|
final _unfocusNode = FocusNode();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -62,17 +38,20 @@ class AccountPageState extends ConsumerState<AccountPage> {
|
|||||||
final textTheme = theme.textTheme;
|
final textTheme = theme.textTheme;
|
||||||
final scale = theme.extension<ScaleScheme>()!;
|
final scale = theme.extension<ScaleScheme>()!;
|
||||||
|
|
||||||
|
final cilState = context.watch<ContactInvitationListCubit>().state;
|
||||||
|
final cilBusy = cilState.busy;
|
||||||
final contactInvitationRecordList =
|
final contactInvitationRecordList =
|
||||||
ref.watch(fetchContactInvitationRecordsProvider).asData?.value ??
|
cilState.state.data?.value ?? const IListConst([]);
|
||||||
const IListConst([]);
|
|
||||||
final contactList = ref.watch(fetchContactListProvider).asData?.value ??
|
final ciState = context.watch<ContactListCubit>().state;
|
||||||
const IListConst([]);
|
final ciBusy = ciState.busy;
|
||||||
|
final contactList = ciState.state.data?.value ?? const IListConst([]);
|
||||||
|
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
child: Column(children: <Widget>[
|
child: Column(children: <Widget>[
|
||||||
if (contactInvitationRecordList.isNotEmpty)
|
if (contactInvitationRecordList.isNotEmpty)
|
||||||
ExpansionTile(
|
ExpansionTile(
|
||||||
tilePadding: EdgeInsets.fromLTRB(8, 0, 8, 0),
|
tilePadding: const EdgeInsets.fromLTRB(8, 0, 8, 0),
|
||||||
backgroundColor: scale.primaryScale.border,
|
backgroundColor: scale.primaryScale.border,
|
||||||
collapsedBackgroundColor: scale.primaryScale.border,
|
collapsedBackgroundColor: scale.primaryScale.border,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
@ -90,10 +69,11 @@ class AccountPageState extends ConsumerState<AccountPage> {
|
|||||||
initiallyExpanded: true,
|
initiallyExpanded: true,
|
||||||
children: [
|
children: [
|
||||||
ContactInvitationListWidget(
|
ContactInvitationListWidget(
|
||||||
contactInvitationRecordList: contactInvitationRecordList)
|
contactInvitationRecordList: contactInvitationRecordList,
|
||||||
|
disabled: cilBusy)
|
||||||
],
|
],
|
||||||
).paddingLTRB(8, 0, 8, 8),
|
).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/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
|
|
||||||
class BottomSheetActionButton extends ConsumerStatefulWidget {
|
class BottomSheetActionButton extends StatefulWidget {
|
||||||
const BottomSheetActionButton(
|
const BottomSheetActionButton(
|
||||||
{required this.bottomSheetBuilder,
|
{required this.bottomSheetBuilder,
|
||||||
required this.builder,
|
required this.builder,
|
||||||
@ -32,8 +31,7 @@ class BottomSheetActionButton extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class BottomSheetActionButtonState
|
class BottomSheetActionButtonState extends State<BottomSheetActionButton> {
|
||||||
extends ConsumerState<BottomSheetActionButton> {
|
|
||||||
bool _showFab = true;
|
bool _showFab = true;
|
||||||
|
|
||||||
@override
|
@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 '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/material.dart';
|
||||||
import 'package:flutter/rendering.dart';
|
import 'package:flutter/rendering.dart';
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:flutter_animate/flutter_animate.dart';
|
import 'package:flutter_animate/flutter_animate.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
import 'package:flutter_translate/flutter_translate.dart';
|
import 'package:flutter_translate/flutter_translate.dart';
|
||||||
import 'package:preload_page_view/preload_page_view.dart';
|
import 'package:preload_page_view/preload_page_view.dart';
|
||||||
import 'package:stylish_bottom_bar/model/bar_items.dart';
|
import 'package:stylish_bottom_bar/model/bar_items.dart';
|
||||||
import 'package:stylish_bottom_bar/stylish_bottom_bar.dart';
|
import 'package:stylish_bottom_bar/stylish_bottom_bar.dart';
|
||||||
|
|
||||||
import '../../components/bottom_sheet_action_button.dart';
|
import '../../../../contact_invitation/contact_invitation.dart';
|
||||||
import '../../components/paste_invite_dialog.dart';
|
import '../../../../theme/theme.dart';
|
||||||
import '../../components/scan_invite_dialog.dart';
|
import '../../../../tools/tools.dart';
|
||||||
import '../../components/send_invite_dialog.dart';
|
import 'account_page.dart';
|
||||||
import '../../entities/local_account.dart';
|
import 'bottom_sheet_action_button.dart';
|
||||||
import '../../proto/proto.dart' as proto;
|
import 'chats_page.dart';
|
||||||
import '../../tools/tools.dart';
|
|
||||||
import '../../veilid_support/veilid_support.dart';
|
|
||||||
import 'account.dart';
|
|
||||||
import 'chats.dart';
|
|
||||||
|
|
||||||
class MainPager extends ConsumerStatefulWidget {
|
class MainPager extends StatefulWidget {
|
||||||
const MainPager(
|
const MainPager({super.key});
|
||||||
{required this.localAccounts,
|
|
||||||
required this.activeUserLogin,
|
|
||||||
required this.account,
|
|
||||||
super.key});
|
|
||||||
|
|
||||||
final IList<LocalAccount> localAccounts;
|
|
||||||
final TypedKey activeUserLogin;
|
|
||||||
final proto.Account account;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
MainPagerState createState() => MainPagerState();
|
MainPagerState createState() => MainPagerState();
|
||||||
|
|
||||||
static MainPagerState? of(BuildContext context) =>
|
static MainPagerState? of(BuildContext context) =>
|
||||||
context.findAncestorStateOfType<MainPagerState>();
|
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>
|
class MainPagerState extends State<MainPager> with TickerProviderStateMixin {
|
||||||
with TickerProviderStateMixin {
|
|
||||||
//////////////////////////////////////////////////////////////////
|
//////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
final _unfocusNode = FocusNode();
|
final _unfocusNode = FocusNode();
|
||||||
@ -136,98 +110,41 @@ class MainPagerState extends ConsumerState<MainPager>
|
|||||||
context: context,
|
context: context,
|
||||||
// ignore: prefer_expression_function_bodies
|
// ignore: prefer_expression_function_bodies
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
return const AlertDialog(
|
return AlertDialog(
|
||||||
shape: RoundedRectangleBorder(
|
shape: const RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.all(Radius.circular(20)),
|
borderRadius: BorderRadius.all(Radius.circular(20)),
|
||||||
),
|
),
|
||||||
contentPadding: EdgeInsets.only(
|
contentPadding: const EdgeInsets.only(
|
||||||
top: 10,
|
top: 10,
|
||||||
),
|
),
|
||||||
title: Text(
|
title: const Text(
|
||||||
'Scan Contact Invite',
|
'Scan Contact Invite',
|
||||||
style: TextStyle(fontSize: 24),
|
style: TextStyle(fontSize: 24),
|
||||||
),
|
),
|
||||||
content: ScanInviteDialog());
|
content: ScanInviteDialog(
|
||||||
|
modalContext: context,
|
||||||
|
));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _newContactInvitationBottomSheetBuilder(
|
Widget _onNewChatBottomSheetBuilder(
|
||||||
// ignore: prefer_expression_function_bodies
|
BuildContext sheetContext, BuildContext context) =>
|
||||||
BuildContext context) {
|
const SizedBox(
|
||||||
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(
|
|
||||||
height: 200,
|
height: 200,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Group and custom chat functionality is not available yet')));
|
'Group and custom chat functionality is not available yet')));
|
||||||
}
|
|
||||||
|
|
||||||
Widget _bottomSheetBuilder(BuildContext context) {
|
Widget _bottomSheetBuilder(BuildContext sheetContext, BuildContext context) {
|
||||||
if (_currentPage == 0) {
|
if (_currentPage == 0) {
|
||||||
// New contact invitation
|
// New contact invitation
|
||||||
return _newContactInvitationBottomSheetBuilder(context);
|
return newContactInvitationBottomSheetBuilder(sheetContext, context);
|
||||||
} else if (_currentPage == 1) {
|
} else if (_currentPage == 1) {
|
||||||
// New chat
|
// New chat
|
||||||
return _onNewChatBottomSheetBuilder(context);
|
return _onNewChatBottomSheetBuilder(sheetContext, context);
|
||||||
} else {
|
} else {
|
||||||
// Unknown error
|
// Unknown error
|
||||||
return waitingPage(context);
|
return debugPage('unknown page');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -250,12 +167,9 @@ class MainPagerState extends ConsumerState<MainPager>
|
|||||||
_currentPage = index;
|
_currentPage = index;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
children: [
|
children: const [
|
||||||
AccountPage(
|
AccountPage(),
|
||||||
localAccounts: widget.localAccounts,
|
ChatsPage(),
|
||||||
activeUserLogin: widget.activeUserLogin,
|
|
||||||
account: widget.account),
|
|
||||||
const ChatsPage(),
|
|
||||||
])),
|
])),
|
||||||
// appBar: AppBar(
|
// appBar: AppBar(
|
||||||
// toolbarHeight: 24,
|
// toolbarHeight: 24,
|
||||||
@ -301,7 +215,8 @@ class MainPagerState extends ConsumerState<MainPager>
|
|||||||
_fabIconList[_currentPage],
|
_fabIconList[_currentPage],
|
||||||
color: scale.secondaryScale.text,
|
color: scale.secondaryScale.text,
|
||||||
),
|
),
|
||||||
bottomSheetBuilder: _bottomSheetBuilder),
|
bottomSheetBuilder: (sheetContext) =>
|
||||||
|
_bottomSheetBuilder(sheetContext, context)),
|
||||||
floatingActionButtonLocation: FloatingActionButtonLocation.endDocked,
|
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