Merge branch 'ui-work' into 'main'

UI Work

See merge request veilid/veilidchat!31
This commit is contained in:
Christien Rioux 2024-08-01 19:55:16 +00:00
commit e2f810f6e5
57 changed files with 2272 additions and 1100 deletions

View File

@ -3,7 +3,9 @@
"title": "VeilidChat" "title": "VeilidChat"
}, },
"menu": { "menu": {
"settings_tooltip": "Settings", "accounts_menu_tooltip": "Accounts Menu",
"contacts_tooltip": "Contacts List",
"new_chat_tooltip": "Start New Chat",
"add_account_tooltip": "Add Account", "add_account_tooltip": "Add Account",
"accounts": "Accounts", "accounts": "Accounts",
"version": "Version" "version": "Version"
@ -12,13 +14,23 @@
"beta_title": "VeilidChat is BETA SOFTWARE", "beta_title": "VeilidChat is BETA SOFTWARE",
"beta_text": "DO NOT USE THIS FOR ANYTHING IMPORTANT\n\nUntil 1.0 is released:\n\n• You should have no expectations of actual privacy, or guarantees of security.\n• You will likely lose accounts, contacts, and messages and need to recreate them.\n\nPlease read our BETA PARTICIPATION GUIDE located here:\n\n" "beta_text": "DO NOT USE THIS FOR ANYTHING IMPORTANT\n\nUntil 1.0 is released:\n\n• You should have no expectations of actual privacy, or guarantees of security.\n• You will likely lose accounts, contacts, and messages and need to recreate them.\n\nPlease read our BETA PARTICIPATION GUIDE located here:\n\n"
}, },
"pager": {
"chats": "Chats",
"contacts": "Contacts"
},
"account": { "account": {
"form_name": "Name", "form_name": "Name",
"form_pronouns": "Pronouns (optional)", "empty_name": "Your name (required)",
"form_pronouns": "Pronouns",
"empty_pronouns": "(optional pronouns)",
"form_about": "About Me",
"empty_about": "Tell your contacts about yourself",
"form_free_message": "Free Message",
"empty_free_message": "Status when availability is 'Free'",
"form_away_message": "Away Message",
"empty_away_message": "Status when availability is 'Away'",
"form_busy_message": "Busy Message",
"empty_busy_message": "Status when availability is 'Busy'",
"form_availability": "Availability",
"form_avatar": "Avatar",
"form_auto_away": "Automatic 'away' detection",
"form_auto_away_timeout": "Auto-away timeout (in minutes)",
"form_lock_type": "Lock Type", "form_lock_type": "Lock Type",
"lock_type_none": "none", "lock_type_none": "none",
"lock_type_pin": "pin", "lock_type_pin": "pin",
@ -30,6 +42,7 @@
"create": "Create", "create": "Create",
"instructions": "This information will be shared with the people you invite to connect with you on VeilidChat.", "instructions": "This information will be shared with the people you invite to connect with you on VeilidChat.",
"error": "Account creation error", "error": "Account creation error",
"network_is_offline": "Network is offline, try again when you're connected",
"name": "Name", "name": "Name",
"pronouns": "Pronouns" "pronouns": "Pronouns"
}, },
@ -101,16 +114,46 @@
"invalid_account_title": "Invalid Account", "invalid_account_title": "Invalid Account",
"invalid_account_text": "Account is invalid, removing from list" "invalid_account_text": "Account is invalid, removing from list"
}, },
"contacts_page": { "contacts_dialog": {
"contacts": "Contacts", "contacts": "Contacts",
"edit_contact": "Edit Contact",
"invitations": "Invitations", "invitations": "Invitations",
"no_contact_selected": "No contact selected",
"new_chat": "New Chat"
},
"contact_list": {
"contacts": "Contacts",
"invite_people": "Invite people to VeilidChat",
"search": "Search contacts",
"invitation": "Invitation",
"loading_contacts": "Loading contacts..." "loading_contacts": "Loading contacts..."
}, },
"contact_form": {
"form_name": "Name",
"form_pronouns": "Pronouns",
"form_about": "About",
"form_status": "Current Status",
"form_nickname": "Nickname",
"form_notes": "Notes",
"form_fingerprint": "Fingerprint",
"form_show_availability": "Show availability",
"save": "Save",
"save_disabled": "Save"
},
"availability": {
"unspecified": "Unspecified",
"offline": "Offline",
"always_show_offline": "Always Show Offline",
"free": "Free",
"busy": "Busy",
"away": "Away"
},
"add_contact_sheet": { "add_contact_sheet": {
"new_contact": "New Contact", "new_contact": "New Contact",
"create_invite": "Create Invitation", "create_invite": "Create\nInvitation",
"scan_invite": "Scan Invitation", "receive_invite": "Receive\nInvitation",
"paste_invite": "Paste Invitation" "scan_invite": "Scan\nInvitation",
"paste_invite": "Paste\nInvitation"
}, },
"add_chat_sheet": { "add_chat_sheet": {
"new_chat": "New Chat" "new_chat": "New Chat"
@ -122,7 +165,9 @@
}, },
"create_invitation_dialog": { "create_invitation_dialog": {
"title": "Create Contact Invitation", "title": "Create Contact Invitation",
"connect_with_me": "Connect with me on VeilidChat!", "me": "me",
"fingerprint": "Fingerprint:",
"connect_with_me": "Connect with {name} on VeilidChat!",
"enter_message_hint": "Enter message for contact (optional)", "enter_message_hint": "Enter message for contact (optional)",
"message_to_contact": "Message to send with invitation (not encrypted)", "message_to_contact": "Message to send with invitation (not encrypted)",
"generate": "Generate Invitation", "generate": "Generate Invitation",
@ -148,6 +193,7 @@
"failed_to_reject": "Failed to reject contact invitation", "failed_to_reject": "Failed to reject contact invitation",
"invalid_invitation": "Invalid invitation", "invalid_invitation": "Invalid invitation",
"try_again_online": "Invitation could not be reached, try again when online", "try_again_online": "Invitation could not be reached, try again when online",
"key_not_found": "Invitation could not be found, it may not be on the network yet",
"protected_with_pin": "Contact invitation is protected with a PIN", "protected_with_pin": "Contact invitation is protected with a PIN",
"protected_with_password": "Contact invitation is protected with a password", "protected_with_password": "Contact invitation is protected with a password",
"invalid_pin": "Invalid PIN", "invalid_pin": "Invalid PIN",
@ -155,7 +201,7 @@
}, },
"waiting_invitation": { "waiting_invitation": {
"accepted": "Contact invitation accepted from {name}", "accepted": "Contact invitation accepted from {name}",
"reject": "Contact invitation was rejected" "rejected": "Contact invitation was rejected"
}, },
"paste_invitation_dialog": { "paste_invitation_dialog": {
"title": "Paste Contact Invite", "title": "Paste Contact Invite",
@ -185,11 +231,6 @@
"reenter_password": "Re-Enter Password To Confirm", "reenter_password": "Re-Enter Password To Confirm",
"password_does_not_match": "Password does not match" "password_does_not_match": "Password does not match"
}, },
"contact_list": {
"invite_people": "Invite people to VeilidChat",
"search": "Search contacts",
"invitation": "Invitation"
},
"chat_list": { "chat_list": {
"search": "Search chats", "search": "Search chats",
"start_a_conversation": "Start A Conversation", "start_a_conversation": "Start A Conversation",
@ -225,6 +266,10 @@
"in_app": "In-app", "in_app": "In-app",
"push": "Push", "push": "Push",
"in_app_or_push": "In-app or Push", "in_app_or_push": "In-app or Push",
"notifications": "Notifications",
"event": "Event",
"sound": "Sound",
"delivery": "Delivery",
"enable_badge": "Enable icon 'badge' bubble", "enable_badge": "Enable icon 'badge' bubble",
"enable_notifications": "Enable notifications", "enable_notifications": "Enable notifications",
"message_notification_content": "Message notification content", "message_notification_content": "Message notification content",

BIN
assets/images/handshake.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

BIN
assets/images/toilet.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@ -158,7 +158,7 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/veilid/ios" :path: ".symlinks/plugins/veilid/ios"
SPEC CHECKSUMS: SPEC CHECKSUMS:
camera_avfoundation: 759172d1a77ae7be0de08fc104cfb79738b8a59e camera_avfoundation: dd002b0330f4981e1bbcb46ae9b62829237459a4
file_saver: 503e386464dbe118f630e17b4c2e1190fa0cf808 file_saver: 503e386464dbe118f630e17b4c2e1190fa0cf808
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
flutter_native_splash: edf599c81f74d093a4daf8e17bd7a018854bc778 flutter_native_splash: edf599c81f74d093a4daf8e17bd7a018854bc778

View File

@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:async_tools/async_tools.dart';
import 'package:protobuf/protobuf.dart'; import 'package:protobuf/protobuf.dart';
import 'package:veilid_support/veilid_support.dart'; import 'package:veilid_support/veilid_support.dart';
@ -7,6 +8,10 @@ import '../../proto/proto.dart' as proto;
import '../account_manager.dart'; import '../account_manager.dart';
typedef AccountRecordState = proto.Account; typedef AccountRecordState = proto.Account;
typedef _sspUpdateState = (
AccountSpec accountSpec,
Future<void> Function() onSuccess
);
/// The saved state of a VeilidChat Account on the DHT /// The saved state of a VeilidChat Account on the DHT
/// Used to synchronize status, profile, and options for a specific account /// Used to synchronize status, profile, and options for a specific account
@ -34,18 +39,62 @@ class AccountRecordCubit extends DefaultDHTRecordCubit<AccountRecordState> {
@override @override
Future<void> close() async { Future<void> close() async {
await _sspUpdate.close();
await super.close(); await super.close();
} }
//////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////
// Public Interface // Public Interface
Future<void> updateProfile(proto.Profile profile) async { void updateAccount(
await record.eventualUpdateProtobuf(proto.Account.fromBuffer, (old) async { AccountSpec accountSpec, Future<void> Function() onSuccess) {
if (old == null || old.profile == profile) { _sspUpdate.updateState((accountSpec, onSuccess), (state) async {
return null; await _updateAccountAsync(state.$1, state.$2);
}
return old.deepCopy()..profile = profile;
}); });
} }
Future<void> _updateAccountAsync(
AccountSpec accountSpec, Future<void> Function() onSuccess) async {
var changed = false;
await record.eventualUpdateProtobuf(proto.Account.fromBuffer, (old) async {
changed = false;
if (old == null) {
return null;
}
final newAccount = old.deepCopy()
..profile.name = accountSpec.name
..profile.pronouns = accountSpec.pronouns
..profile.about = accountSpec.about
..profile.availability = accountSpec.availability
..profile.status = accountSpec.status
//..profile.avatar =
..profile.timestamp = Veilid.instance.now().toInt64()
..invisible = accountSpec.invisible
..autodetectAway = accountSpec.autoAway
..autoAwayTimeoutMin = accountSpec.autoAwayTimeout
..freeMessage = accountSpec.freeMessage
..awayMessage = accountSpec.awayMessage
..busyMessage = accountSpec.busyMessage;
if (newAccount.profile != old.profile ||
newAccount.invisible != old.invisible ||
newAccount.autodetectAway != old.autodetectAway ||
newAccount.autoAwayTimeoutMin != old.autoAwayTimeoutMin ||
newAccount.freeMessage != old.freeMessage ||
newAccount.busyMessage != old.busyMessage ||
newAccount.awayMessage != old.awayMessage) {
changed = true;
}
if (changed) {
return newAccount;
}
return null;
});
if (changed) {
await onSuccess();
}
}
final _sspUpdate = SingleStateProcessor<_sspUpdateState>();
} }

View File

@ -0,0 +1,55 @@
import 'package:flutter/widgets.dart';
import '../../proto/proto.dart' as proto;
/// Profile and Account configurable fields
/// Some are publicly visible via the proto.Profile
/// Some are privately held as proto.Account configurations
class AccountSpec {
AccountSpec(
{required this.name,
required this.pronouns,
required this.about,
required this.availability,
required this.invisible,
required this.freeMessage,
required this.awayMessage,
required this.busyMessage,
required this.avatar,
required this.autoAway,
required this.autoAwayTimeout});
String get status {
late final String status;
switch (availability) {
case proto.Availability.AVAILABILITY_AWAY:
status = awayMessage;
break;
case proto.Availability.AVAILABILITY_BUSY:
status = busyMessage;
break;
case proto.Availability.AVAILABILITY_FREE:
status = freeMessage;
break;
case proto.Availability.AVAILABILITY_UNSPECIFIED:
case proto.Availability.AVAILABILITY_OFFLINE:
status = '';
break;
}
return status;
}
////////////////////////////////////////////////////////////////////////////
String name;
String pronouns;
String about;
proto.Availability availability;
bool invisible;
String freeMessage;
String awayMessage;
String busyMessage;
ImageProvider? avatar;
bool autoAway;
int autoAwayTimeout;
}

View File

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

View File

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

View File

@ -133,14 +133,14 @@ class AccountRepository {
/// with the identity instance, stores the account in the identity key and /// with the identity instance, stores the account in the identity key and
/// then logs into that account with no password set at this time /// then logs into that account with no password set at this time
Future<WritableSuperIdentity> createWithNewSuperIdentity( Future<WritableSuperIdentity> createWithNewSuperIdentity(
proto.Profile newProfile) async { AccountSpec accountSpec) async {
log.debug('Creating super identity'); log.debug('Creating super identity');
final wsi = await WritableSuperIdentity.create(); final wsi = await WritableSuperIdentity.create();
try { try {
final localAccount = await _newLocalAccount( final localAccount = await _newLocalAccount(
superIdentity: wsi.superIdentity, superIdentity: wsi.superIdentity,
identitySecret: wsi.identitySecret, identitySecret: wsi.identitySecret,
newProfile: newProfile); accountSpec: accountSpec);
// Log in the new account by default with no pin // Log in the new account by default with no pin
final ok = await login( final ok = await login(
@ -154,15 +154,13 @@ class AccountRepository {
} }
} }
Future<void> editAccountProfile( Future<void> updateLocalAccount(
TypedKey superIdentityRecordKey, proto.Profile newProfile) async { TypedKey superIdentityRecordKey, AccountSpec accountSpec) async {
log.debug('Editing profile for $superIdentityRecordKey');
final localAccounts = await _localAccounts.get(); final localAccounts = await _localAccounts.get();
final newLocalAccounts = localAccounts.replaceFirstWhere( final newLocalAccounts = localAccounts.replaceFirstWhere(
(x) => x.superIdentity.recordKey == superIdentityRecordKey, (x) => x.superIdentity.recordKey == superIdentityRecordKey,
(localAccount) => localAccount!.copyWith(name: newProfile.name)); (localAccount) => localAccount!.copyWith(name: accountSpec.name));
await _localAccounts.set(newLocalAccounts); await _localAccounts.set(newLocalAccounts);
_streamController.add(AccountRepositoryChange.localAccounts); _streamController.add(AccountRepositoryChange.localAccounts);
@ -248,7 +246,7 @@ class AccountRepository {
Future<LocalAccount> _newLocalAccount( Future<LocalAccount> _newLocalAccount(
{required SuperIdentity superIdentity, {required SuperIdentity superIdentity,
required SecretKey identitySecret, required SecretKey identitySecret,
required proto.Profile newProfile, required AccountSpec accountSpec,
EncryptionKeyType encryptionKeyType = EncryptionKeyType.none, EncryptionKeyType encryptionKeyType = EncryptionKeyType.none,
String encryptionKey = ''}) async { String encryptionKey = ''}) async {
log.debug('Creating new local account'); log.debug('Creating new local account');
@ -285,7 +283,10 @@ class AccountRepository {
// Make account object // Make account object
final account = proto.Account() final account = proto.Account()
..profile = newProfile ..profile.name = accountSpec.name
..profile.pronouns = accountSpec.pronouns
..profile.about = accountSpec.about
..profile.status = accountSpec.status
..contactList = contactList.toProto() ..contactList = contactList.toProto()
..contactInvitationRecords = contactInvitationRecords.toProto() ..contactInvitationRecords = contactInvitationRecords.toProto()
..chatList = chatRecords.toProto(); ..chatList = chatRecords.toProto();
@ -309,7 +310,7 @@ class AccountRepository {
encryptionKeyType: encryptionKeyType, encryptionKeyType: encryptionKeyType,
biometricsEnabled: false, biometricsEnabled: false,
hiddenAccount: false, hiddenAccount: false,
name: newProfile.name, name: accountSpec.name,
); );
// Add local account object to internal store // Add local account object to internal store

View File

@ -4,10 +4,8 @@ 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_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:flutter_translate/flutter_translate.dart'; import 'package:flutter_translate/flutter_translate.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:protobuf/protobuf.dart';
import 'package:veilid_support/veilid_support.dart'; import 'package:veilid_support/veilid_support.dart';
import '../../layout/default_app_bar.dart'; import '../../layout/default_app_bar.dart';
@ -17,12 +15,12 @@ import '../../theme/theme.dart';
import '../../tools/tools.dart'; import '../../tools/tools.dart';
import '../../veilid_processor/veilid_processor.dart'; import '../../veilid_processor/veilid_processor.dart';
import '../account_manager.dart'; import '../account_manager.dart';
import 'profile_edit_form.dart'; import 'edit_profile_form.dart';
class EditAccountPage extends StatefulWidget { class EditAccountPage extends StatefulWidget {
const EditAccountPage( const EditAccountPage(
{required this.superIdentityRecordKey, {required this.superIdentityRecordKey,
required this.existingProfile, required this.existingAccount,
required this.accountRecord, required this.accountRecord,
super.key}); super.key});
@ -30,7 +28,7 @@ class EditAccountPage extends StatefulWidget {
State createState() => _EditAccountPageState(); State createState() => _EditAccountPageState();
final TypedKey superIdentityRecordKey; final TypedKey superIdentityRecordKey;
final proto.Profile existingProfile; final proto.Account existingAccount;
final OwnedDHTRecordPointer accountRecord; final OwnedDHTRecordPointer accountRecord;
@override @override
void debugFillProperties(DiagnosticPropertiesBuilder properties) { void debugFillProperties(DiagnosticPropertiesBuilder properties) {
@ -38,8 +36,8 @@ class EditAccountPage extends StatefulWidget {
properties properties
..add(DiagnosticsProperty<TypedKey>( ..add(DiagnosticsProperty<TypedKey>(
'superIdentityRecordKey', superIdentityRecordKey)) 'superIdentityRecordKey', superIdentityRecordKey))
..add(DiagnosticsProperty<proto.Profile>( ..add(DiagnosticsProperty<proto.Account>(
'existingProfile', existingProfile)) 'existingAccount', existingAccount))
..add(DiagnosticsProperty<OwnedDHTRecordPointer>( ..add(DiagnosticsProperty<OwnedDHTRecordPointer>(
'accountRecord', accountRecord)); 'accountRecord', accountRecord));
} }
@ -52,17 +50,33 @@ class _EditAccountPageState extends WindowSetupState<EditAccountPage> {
orientationCapability: OrientationCapability.portraitOnly); orientationCapability: OrientationCapability.portraitOnly);
Widget _editAccountForm(BuildContext context, Widget _editAccountForm(BuildContext context,
{required Future<void> Function(GlobalKey<FormBuilderState>) {required Future<void> Function(AccountSpec) onUpdate}) =>
onSubmit}) =>
EditProfileForm( EditProfileForm(
header: translate('edit_account_page.header'), header: translate('edit_account_page.header'),
instructions: translate('edit_account_page.instructions'), instructions: translate('edit_account_page.instructions'),
submitText: translate('edit_account_page.update'), submitText: translate('edit_account_page.update'),
submitDisabledText: translate('button.waiting_for_network'), submitDisabledText: translate('button.waiting_for_network'),
onSubmit: onSubmit, onUpdate: onUpdate,
initialValueCallback: (key) => switch (key) { initialValueCallback: (key) => switch (key) {
EditProfileForm.formFieldName => widget.existingProfile.name, EditProfileForm.formFieldName => widget.existingAccount.profile.name,
EditProfileForm.formFieldPronouns => widget.existingProfile.pronouns, EditProfileForm.formFieldPronouns =>
widget.existingAccount.profile.pronouns,
EditProfileForm.formFieldAbout =>
widget.existingAccount.profile.about,
EditProfileForm.formFieldAvailability =>
widget.existingAccount.profile.availability,
EditProfileForm.formFieldFreeMessage =>
widget.existingAccount.freeMessage,
EditProfileForm.formFieldAwayMessage =>
widget.existingAccount.awayMessage,
EditProfileForm.formFieldBusyMessage =>
widget.existingAccount.busyMessage,
EditProfileForm.formFieldAvatar =>
widget.existingAccount.profile.avatar,
EditProfileForm.formFieldAutoAway =>
widget.existingAccount.autodetectAway,
EditProfileForm.formFieldAutoAwayTimeout =>
widget.existingAccount.autoAwayTimeoutMin.toString(),
String() => throw UnimplementedError(), String() => throw UnimplementedError(),
}, },
); );
@ -200,61 +214,24 @@ class _EditAccountPageState extends WindowSetupState<EditAccountPage> {
} }
} }
Future<void> _onSubmit(GlobalKey<FormBuilderState> formKey) async { Future<void> _onUpdate(AccountSpec accountSpec) async {
// dismiss the keyboard by unfocusing the textfield // Look up account cubit for this specific account
FocusScope.of(context).unfocus(); final perAccountCollectionBlocMapCubit =
context.read<PerAccountCollectionBlocMapCubit>();
try { final accountRecordCubit = await perAccountCollectionBlocMapCubit.operate(
final name = formKey widget.superIdentityRecordKey,
.currentState!.fields[EditProfileForm.formFieldName]!.value as String; closure: (c) async => c.accountRecordCubit);
final pronouns = formKey.currentState! if (accountRecordCubit == null) {
.fields[EditProfileForm.formFieldPronouns]!.value as String? ?? return;
'';
final newProfile = widget.existingProfile.deepCopy()
..name = name
..pronouns = pronouns
..timestamp = Veilid.instance.now().toInt64();
setState(() {
_isInAsyncCall = true;
});
try {
// Look up account cubit for this specific account
final perAccountCollectionBlocMapCubit =
context.read<PerAccountCollectionBlocMapCubit>();
final accountRecordCubit = await perAccountCollectionBlocMapCubit
.operate(widget.superIdentityRecordKey,
closure: (c) async => c.accountRecordCubit);
if (accountRecordCubit == null) {
return;
}
// Update account profile DHT record
// This triggers ConversationCubits to update
await accountRecordCubit.updateProfile(newProfile);
// Update local account profile
await AccountRepository.instance
.editAccountProfile(widget.superIdentityRecordKey, newProfile);
if (mounted) {
Navigator.canPop(context)
? GoRouterHelper(context).pop()
: GoRouterHelper(context).go('/');
}
} finally {
if (mounted) {
setState(() {
_isInAsyncCall = false;
});
}
}
} on Exception catch (e) {
if (mounted) {
await showErrorModal(
context, translate('edit_account_page.error'), 'Exception: $e');
}
} }
// Update account profile DHT record
// This triggers ConversationCubits to update
accountRecordCubit.updateAccount(accountSpec, () async {
// Update local account profile
await AccountRepository.instance
.updateLocalAccount(widget.superIdentityRecordKey, accountSpec);
});
} }
@override @override
@ -286,7 +263,7 @@ class _EditAccountPageState extends WindowSetupState<EditAccountPage> {
child: Column(children: [ child: Column(children: [
_editAccountForm( _editAccountForm(
context, context,
onSubmit: _onSubmit, onUpdate: _onUpdate,
).paddingLTRB(0, 0, 0, 32), ).paddingLTRB(0, 0, 0, 32),
OptionBox( OptionBox(
instructions: instructions:

View File

@ -0,0 +1,346 @@
import 'package:async_tools/async_tools.dart';
import 'package:awesome_extensions/awesome_extensions.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:flutter_translate/flutter_translate.dart';
import 'package:form_builder_validators/form_builder_validators.dart';
import '../../contacts/contacts.dart';
import '../../proto/proto.dart' as proto;
import '../../theme/theme.dart';
import '../models/models.dart';
const _kDoUpdateSubmit = 'doUpdateSubmit';
class EditProfileForm extends StatefulWidget {
const EditProfileForm({
required this.header,
required this.instructions,
required this.submitText,
required this.submitDisabledText,
required this.initialValueCallback,
this.onUpdate,
this.onSubmit,
super.key,
});
@override
State createState() => _EditProfileFormState();
final String header;
final String instructions;
final Future<void> Function(AccountSpec)? onUpdate;
final Future<void> Function(AccountSpec)? onSubmit;
final String submitText;
final String submitDisabledText;
final Object Function(String key) initialValueCallback;
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties
..add(StringProperty('header', header))
..add(StringProperty('instructions', instructions))
..add(ObjectFlagProperty<Future<void> Function(AccountSpec)?>.has(
'onUpdate', onUpdate))
..add(StringProperty('submitText', submitText))
..add(StringProperty('submitDisabledText', submitDisabledText))
..add(ObjectFlagProperty<Object Function(String key)?>.has(
'initialValueCallback', initialValueCallback))
..add(ObjectFlagProperty<Future<void> Function(AccountSpec)?>.has(
'onSubmit', onSubmit));
}
static const String formFieldName = 'name';
static const String formFieldPronouns = 'pronouns';
static const String formFieldAbout = 'about';
static const String formFieldAvailability = 'availability';
static const String formFieldFreeMessage = 'free_message';
static const String formFieldAwayMessage = 'away_message';
static const String formFieldBusyMessage = 'busy_message';
static const String formFieldAvatar = 'avatar';
static const String formFieldAutoAway = 'auto_away';
static const String formFieldAutoAwayTimeout = 'auto_away_timeout';
}
class _EditProfileFormState extends State<EditProfileForm> {
final _formKey = GlobalKey<FormBuilderState>();
@override
void initState() {
_autoAwayEnabled =
widget.initialValueCallback(EditProfileForm.formFieldAutoAway) as bool;
super.initState();
}
FormBuilderDropdown<proto.Availability> _availabilityDropDown(
BuildContext context) {
final initialValueX =
widget.initialValueCallback(EditProfileForm.formFieldAvailability)
as proto.Availability;
final initialValue =
initialValueX == proto.Availability.AVAILABILITY_UNSPECIFIED
? proto.Availability.AVAILABILITY_FREE
: initialValueX;
final availabilities = [
proto.Availability.AVAILABILITY_FREE,
proto.Availability.AVAILABILITY_AWAY,
proto.Availability.AVAILABILITY_BUSY,
proto.Availability.AVAILABILITY_OFFLINE,
];
return FormBuilderDropdown<proto.Availability>(
name: EditProfileForm.formFieldAvailability,
initialValue: initialValue,
decoration: InputDecoration(
floatingLabelBehavior: FloatingLabelBehavior.always,
labelText: translate('account.form_availability'),
hintText: translate('account.empty_busy_message')),
items: availabilities
.map((x) => DropdownMenuItem<proto.Availability>(
value: x,
child: Row(mainAxisSize: MainAxisSize.min, children: [
AvailabilityWidget.availabilityIcon(x),
Text(x == proto.Availability.AVAILABILITY_OFFLINE
? translate('availability.always_show_offline')
: AvailabilityWidget.availabilityName(x))
.paddingLTRB(8, 0, 0, 0),
])))
.toList(),
);
}
AccountSpec _makeAccountSpec() {
final name = _formKey
.currentState!.fields[EditProfileForm.formFieldName]!.value as String;
final pronouns = _formKey.currentState!
.fields[EditProfileForm.formFieldPronouns]!.value as String;
final about = _formKey
.currentState!.fields[EditProfileForm.formFieldAbout]!.value as String;
final availability = _formKey
.currentState!
.fields[EditProfileForm.formFieldAvailability]!
.value as proto.Availability;
final invisible = availability == proto.Availability.AVAILABILITY_OFFLINE;
final freeMessage = _formKey.currentState!
.fields[EditProfileForm.formFieldFreeMessage]!.value as String;
final awayMessage = _formKey.currentState!
.fields[EditProfileForm.formFieldAwayMessage]!.value as String;
final busyMessage = _formKey.currentState!
.fields[EditProfileForm.formFieldBusyMessage]!.value as String;
final autoAway = _formKey
.currentState!.fields[EditProfileForm.formFieldAutoAway]!.value as bool;
final autoAwayTimeoutString = _formKey.currentState!
.fields[EditProfileForm.formFieldAutoAwayTimeout]!.value as String;
final autoAwayTimeout = int.parse(autoAwayTimeoutString);
return AccountSpec(
name: name,
pronouns: pronouns,
about: about,
availability: availability,
invisible: invisible,
freeMessage: freeMessage,
awayMessage: awayMessage,
busyMessage: busyMessage,
avatar: null,
autoAway: autoAway,
autoAwayTimeout: autoAwayTimeout);
}
Widget _editProfileForm(
BuildContext context,
) {
final theme = Theme.of(context);
final scale = theme.extension<ScaleScheme>()!;
final scaleConfig = theme.extension<ScaleConfig>()!;
final textTheme = theme.textTheme;
late final Color border;
if (scaleConfig.useVisualIndicators && !scaleConfig.preferBorders) {
border = scale.primaryScale.elementBackground;
} else {
border = scale.primaryScale.border;
}
return FormBuilder(
key: _formKey,
autovalidateMode: AutovalidateMode.onUserInteraction,
child: Column(
children: [
AvatarWidget(
name: _formKey.currentState?.value[EditProfileForm.formFieldName]
as String? ??
'?',
size: 128,
borderColor: border,
foregroundColor: scale.primaryScale.primaryText,
backgroundColor: scale.primaryScale.primary,
scaleConfig: scaleConfig,
textStyle: theme.textTheme.titleLarge!.copyWith(fontSize: 64),
).paddingLTRB(0, 0, 0, 16),
FormBuilderTextField(
autofocus: true,
name: EditProfileForm.formFieldName,
initialValue: widget
.initialValueCallback(EditProfileForm.formFieldName) as String,
decoration: InputDecoration(
floatingLabelBehavior: FloatingLabelBehavior.always,
labelText: translate('account.form_name'),
hintText: translate('account.empty_name')),
maxLength: 64,
// The validator receives the text that the user has entered.
validator: FormBuilderValidators.compose([
FormBuilderValidators.required(),
]),
textInputAction: TextInputAction.next,
).onFocusChange(_onFocusChange),
FormBuilderTextField(
name: EditProfileForm.formFieldPronouns,
initialValue:
widget.initialValueCallback(EditProfileForm.formFieldPronouns)
as String,
maxLength: 64,
decoration: InputDecoration(
floatingLabelBehavior: FloatingLabelBehavior.always,
labelText: translate('account.form_pronouns'),
hintText: translate('account.empty_pronouns')),
textInputAction: TextInputAction.next,
).onFocusChange(_onFocusChange),
FormBuilderTextField(
name: EditProfileForm.formFieldAbout,
initialValue: widget
.initialValueCallback(EditProfileForm.formFieldAbout) as String,
maxLength: 1024,
maxLines: 8,
minLines: 1,
decoration: InputDecoration(
floatingLabelBehavior: FloatingLabelBehavior.always,
labelText: translate('account.form_about'),
hintText: translate('account.empty_about')),
textInputAction: TextInputAction.newline,
).onFocusChange(_onFocusChange),
_availabilityDropDown(context)
.paddingLTRB(0, 0, 0, 16)
.onFocusChange(_onFocusChange),
FormBuilderTextField(
name: EditProfileForm.formFieldFreeMessage,
initialValue: widget.initialValueCallback(
EditProfileForm.formFieldFreeMessage) as String,
maxLength: 128,
decoration: InputDecoration(
floatingLabelBehavior: FloatingLabelBehavior.always,
labelText: translate('account.form_free_message'),
hintText: translate('account.empty_free_message')),
textInputAction: TextInputAction.next,
).onFocusChange(_onFocusChange),
FormBuilderTextField(
name: EditProfileForm.formFieldAwayMessage,
initialValue: widget.initialValueCallback(
EditProfileForm.formFieldAwayMessage) as String,
maxLength: 128,
decoration: InputDecoration(
floatingLabelBehavior: FloatingLabelBehavior.always,
labelText: translate('account.form_away_message'),
hintText: translate('account.empty_away_message')),
textInputAction: TextInputAction.next,
).onFocusChange(_onFocusChange),
FormBuilderTextField(
name: EditProfileForm.formFieldBusyMessage,
initialValue: widget.initialValueCallback(
EditProfileForm.formFieldBusyMessage) as String,
maxLength: 128,
decoration: InputDecoration(
floatingLabelBehavior: FloatingLabelBehavior.always,
labelText: translate('account.form_busy_message'),
hintText: translate('account.empty_busy_message')),
textInputAction: TextInputAction.next,
).onFocusChange(_onFocusChange),
FormBuilderCheckbox(
name: EditProfileForm.formFieldAutoAway,
initialValue:
widget.initialValueCallback(EditProfileForm.formFieldAutoAway)
as bool,
side: BorderSide(color: scale.primaryScale.border, width: 2),
title: Text(translate('account.form_auto_away'),
style: textTheme.labelMedium),
onChanged: (v) {
setState(() {
_autoAwayEnabled = v ?? false;
});
},
).onFocusChange(_onFocusChange),
FormBuilderTextField(
name: EditProfileForm.formFieldAutoAwayTimeout,
enabled: _autoAwayEnabled,
initialValue: widget.initialValueCallback(
EditProfileForm.formFieldAutoAwayTimeout) as String,
decoration: InputDecoration(
labelText: translate('account.form_auto_away_timeout'),
),
validator: FormBuilderValidators.positiveNumber(),
textInputAction: TextInputAction.next,
).onFocusChange(_onFocusChange),
Row(children: [
const Spacer(),
Text(widget.instructions).toCenter().flexible(flex: 6),
const Spacer(),
]).paddingSymmetric(vertical: 4),
if (widget.onSubmit != null)
ElevatedButton(
onPressed: widget.onSubmit == null ? null : _doSubmit,
child: Row(mainAxisSize: MainAxisSize.min, children: [
const Icon(Icons.check, size: 16).paddingLTRB(0, 0, 4, 0),
Text((widget.onSubmit == null)
? widget.submitDisabledText
: widget.submitText)
.paddingLTRB(0, 0, 4, 0)
]),
)
],
),
);
}
void _onFocusChange(bool focused) {
if (!focused) {
_doUpdate();
}
}
void _doUpdate() {
final onUpdate = widget.onUpdate;
if (onUpdate != null) {
singleFuture((this, _kDoUpdateSubmit), () async {
if (_formKey.currentState?.saveAndValidate() ?? false) {
final aus = _makeAccountSpec();
await onUpdate(aus);
}
});
}
}
void _doSubmit() {
final onSubmit = widget.onSubmit;
if (onSubmit != null) {
singleFuture((this, _kDoUpdateSubmit), () async {
if (_formKey.currentState?.saveAndValidate() ?? false) {
final aus = _makeAccountSpec();
await onSubmit(aus);
}
});
}
}
@override
Widget build(BuildContext context) => _editProfileForm(
context,
);
///////////////////////////////////////////////////////////////////////////
late bool _autoAwayEnabled;
}

View File

@ -3,17 +3,17 @@ import 'dart:async';
import 'package:awesome_extensions/awesome_extensions.dart'; import 'package:awesome_extensions/awesome_extensions.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:flutter_translate/flutter_translate.dart'; import 'package:flutter_translate/flutter_translate.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../layout/default_app_bar.dart'; import '../../layout/default_app_bar.dart';
import '../../notifications/cubits/notifications_cubit.dart';
import '../../proto/proto.dart' as proto; import '../../proto/proto.dart' as proto;
import '../../theme/theme.dart'; import '../../theme/theme.dart';
import '../../tools/tools.dart'; import '../../tools/tools.dart';
import '../../veilid_processor/veilid_processor.dart'; import '../../veilid_processor/veilid_processor.dart';
import '../account_manager.dart'; import '../account_manager.dart';
import 'profile_edit_form.dart'; import 'edit_profile_form.dart';
class NewAccountPage extends StatefulWidget { class NewAccountPage extends StatefulWidget {
const NewAccountPage({super.key}); const NewAccountPage({super.key});
@ -28,47 +28,73 @@ class _NewAccountPageState extends WindowSetupState<NewAccountPage> {
titleBarStyle: TitleBarStyle.normal, titleBarStyle: TitleBarStyle.normal,
orientationCapability: OrientationCapability.portraitOnly); orientationCapability: OrientationCapability.portraitOnly);
Widget _newAccountForm(BuildContext context, Object _defaultAccountValues(String key) {
{required Future<void> Function(GlobalKey<FormBuilderState>) onSubmit}) { switch (key) {
final networkReady = context case EditProfileForm.formFieldName:
.watch<ConnectionStateCubit>() return '';
.state case EditProfileForm.formFieldPronouns:
.asData return '';
?.value case EditProfileForm.formFieldAbout:
.isPublicInternetReady ?? return '';
false; case EditProfileForm.formFieldAvailability:
final canSubmit = networkReady; return proto.Availability.AVAILABILITY_FREE;
case EditProfileForm.formFieldFreeMessage:
return EditProfileForm( return '';
header: translate('new_account_page.header'), case EditProfileForm.formFieldAwayMessage:
instructions: translate('new_account_page.instructions'), return '';
submitText: translate('new_account_page.create'), case EditProfileForm.formFieldBusyMessage:
submitDisabledText: translate('button.waiting_for_network'), return '';
onSubmit: !canSubmit ? null : onSubmit); // case EditProfileForm.formFieldAvatar:
// return null;
case EditProfileForm.formFieldAutoAway:
return false;
case EditProfileForm.formFieldAutoAwayTimeout:
return '15';
default:
throw StateError('missing form element');
}
} }
Future<void> _onSubmit(GlobalKey<FormBuilderState> formKey) async { Widget _newAccountForm(
BuildContext context,
) =>
EditProfileForm(
header: translate('new_account_page.header'),
instructions: translate('new_account_page.instructions'),
submitText: translate('new_account_page.create'),
submitDisabledText: translate('button.waiting_for_network'),
initialValueCallback: _defaultAccountValues,
onSubmit: _onSubmit);
Future<void> _onSubmit(AccountSpec accountSpec) async {
// dismiss the keyboard by unfocusing the textfield // dismiss the keyboard by unfocusing the textfield
FocusScope.of(context).unfocus(); FocusScope.of(context).unfocus();
try { try {
final name = formKey
.currentState!.fields[EditProfileForm.formFieldName]!.value as String;
final pronouns = formKey.currentState!
.fields[EditProfileForm.formFieldPronouns]!.value as String? ??
'';
final newProfile = proto.Profile()
..name = name
..pronouns = pronouns;
setState(() { setState(() {
_isInAsyncCall = true; _isInAsyncCall = true;
}); });
try { try {
final networkReady = context
.read<ConnectionStateCubit>()
.state
.asData
?.value
.isPublicInternetReady ??
false;
final canSubmit = networkReady;
if (!canSubmit) {
context.read<NotificationsCubit>().error(
text: translate('new_account_page.network_is_offline'),
title: translate('new_account_page.error'));
return;
}
final writableSuperIdentity = await AccountRepository.instance final writableSuperIdentity = await AccountRepository.instance
.createWithNewSuperIdentity(newProfile); .createWithNewSuperIdentity(accountSpec);
GoRouterHelper(context).pushReplacement('/new_account/recovery_key', GoRouterHelper(context).pushReplacement('/new_account/recovery_key',
extra: [writableSuperIdentity, newProfile.name]); extra: [writableSuperIdentity, accountSpec.name]);
} finally { } finally {
if (mounted) { if (mounted) {
setState(() { setState(() {
@ -111,7 +137,6 @@ class _NewAccountPageState extends WindowSetupState<NewAccountPage> {
body: SingleChildScrollView( body: SingleChildScrollView(
child: _newAccountForm( child: _newAccountForm(
context, context,
onSubmit: _onSubmit,
)).paddingSymmetric(horizontal: 24, vertical: 8), )).paddingSymmetric(horizontal: 24, vertical: 8),
).withModalHUD(context, displayModalHUD); ).withModalHUD(context, displayModalHUD);
} }

View File

@ -1,118 +0,0 @@
import 'package:awesome_extensions/awesome_extensions.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:flutter_translate/flutter_translate.dart';
import 'package:form_builder_validators/form_builder_validators.dart';
class EditProfileForm extends StatefulWidget {
const EditProfileForm({
required this.header,
required this.instructions,
required this.submitText,
required this.submitDisabledText,
super.key,
this.onSubmit,
this.initialValueCallback,
});
@override
State createState() => _EditProfileFormState();
final String header;
final String instructions;
final Future<void> Function(GlobalKey<FormBuilderState>)? onSubmit;
final String submitText;
final String submitDisabledText;
final Object? Function(String key)? initialValueCallback;
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties
..add(StringProperty('header', header))
..add(StringProperty('instructions', instructions))
..add(ObjectFlagProperty<
Future<void> Function(
GlobalKey<FormBuilderState> p1)?>.has('onSubmit', onSubmit))
..add(StringProperty('submitText', submitText))
..add(StringProperty('submitDisabledText', submitDisabledText))
..add(ObjectFlagProperty<Object? Function(String key)?>.has(
'initialValueCallback', initialValueCallback));
}
static const String formFieldName = 'name';
static const String formFieldPronouns = 'pronouns';
}
class _EditProfileFormState extends State<EditProfileForm> {
final _formKey = GlobalKey<FormBuilderState>();
@override
void initState() {
super.initState();
}
Widget _editProfileForm(
BuildContext context,
) =>
FormBuilder(
key: _formKey,
child: Column(
children: [
Text(widget.header)
.textStyle(context.headlineSmall)
.paddingSymmetric(vertical: 16),
FormBuilderTextField(
autofocus: true,
name: EditProfileForm.formFieldName,
initialValue: widget.initialValueCallback
?.call(EditProfileForm.formFieldName) as String?,
decoration:
InputDecoration(labelText: translate('account.form_name')),
maxLength: 64,
// The validator receives the text that the user has entered.
validator: FormBuilderValidators.compose([
FormBuilderValidators.required(),
]),
textInputAction: TextInputAction.next,
),
FormBuilderTextField(
name: EditProfileForm.formFieldPronouns,
initialValue: widget.initialValueCallback
?.call(EditProfileForm.formFieldPronouns) as String?,
maxLength: 64,
decoration: InputDecoration(
labelText: translate('account.form_pronouns')),
textInputAction: TextInputAction.next,
),
Row(children: [
const Spacer(),
Text(widget.instructions).toCenter().flexible(flex: 6),
const Spacer(),
]).paddingSymmetric(vertical: 4),
ElevatedButton(
onPressed: widget.onSubmit == null
? null
: () async {
if (_formKey.currentState?.saveAndValidate() ?? false) {
await widget.onSubmit!(_formKey);
}
},
child: Row(mainAxisSize: MainAxisSize.min, children: [
const Icon(Icons.check, size: 16).paddingLTRB(0, 0, 4, 0),
Text((widget.onSubmit == null)
? widget.submitDisabledText
: widget.submitText)
.paddingLTRB(0, 0, 4, 0)
]),
).paddingSymmetric(vertical: 4).alignAtCenterRight(),
],
),
);
@override
Widget build(BuildContext context) => _editProfileForm(
context,
);
}

View File

@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_translate/flutter_translate.dart'; import 'package:flutter_translate/flutter_translate.dart';
import '../../chat/cubits/active_chat_cubit.dart'; import '../../chat/cubits/active_chat_cubit.dart';
import '../../contacts/contacts.dart';
import '../../proto/proto.dart' as proto; import '../../proto/proto.dart' as proto;
import '../../theme/theme.dart'; import '../../theme/theme.dart';
import '../chat_list.dart'; import '../chat_list.dart';
@ -23,28 +24,33 @@ class ChatSingleContactItemWidget extends StatelessWidget {
Widget build( Widget build(
BuildContext context, BuildContext context,
) { ) {
final theme = Theme.of(context);
final scale = theme.extension<ScaleScheme>()!;
final scaleConfig = theme.extension<ScaleConfig>()!;
final activeChatCubit = context.watch<ActiveChatCubit>(); final activeChatCubit = context.watch<ActiveChatCubit>();
final localConversationRecordKey = final localConversationRecordKey =
_contact.localConversationRecordKey.toVeilid(); _contact.localConversationRecordKey.toVeilid();
final selected = activeChatCubit.state == localConversationRecordKey; final selected = activeChatCubit.state == localConversationRecordKey;
late final String title; final name = _contact.nameOrNickname;
late final String subtitle; final title = _contact.displayName;
if (_contact.nickname.isNotEmpty) { final subtitle = _contact.profile.status;
title = _contact.nickname;
if (_contact.profile.pronouns.isNotEmpty) { final avatar = AvatarWidget(
subtitle = '${_contact.profile.name} (${_contact.profile.pronouns})'; name: name,
} else { size: 34,
subtitle = _contact.profile.name; borderColor: _disabled
} ? scale.grayScale.primaryText
} else { : scale.secondaryScale.primaryText,
title = _contact.profile.name; foregroundColor: _disabled
if (_contact.profile.pronouns.isNotEmpty) { ? scale.grayScale.primaryText
subtitle = '(${_contact.profile.pronouns})'; : scale.secondaryScale.primaryText,
} else { backgroundColor:
subtitle = ''; _disabled ? scale.grayScale.primary : scale.secondaryScale.primary,
} scaleConfig: scaleConfig,
} textStyle: theme.textTheme.titleLarge!,
);
return SliderTile( return SliderTile(
key: ObjectKey(_contact), key: ObjectKey(_contact),
@ -53,7 +59,8 @@ class ChatSingleContactItemWidget extends StatelessWidget {
tileScale: ScaleKind.secondary, tileScale: ScaleKind.secondary,
title: title, title: title,
subtitle: subtitle, subtitle: subtitle,
icon: Icons.chat, leading: avatar,
trailing: AvailabilityWidget(availability: _contact.profile.availability),
onTap: () { onTap: () {
singleFuture(activeChatCubit, () async { singleFuture(activeChatCubit, () async {
activeChatCubit.setActiveChat(localConversationRecordKey); activeChatCubit.setActiveChat(localConversationRecordKey);

View File

@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:async_tools/async_tools.dart';
import 'package:bloc_advanced_tools/bloc_advanced_tools.dart'; import 'package:bloc_advanced_tools/bloc_advanced_tools.dart';
import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:fixnum/fixnum.dart'; import 'package:fixnum/fixnum.dart';
@ -214,9 +215,11 @@ class ContactInvitationListCubit
} }
} }
Future<ValidContactInvitation?> validateInvitation( Future<ValidContactInvitation?> validateInvitation({
{required Uint8List inviteData, required Uint8List inviteData,
required GetEncryptionKeyCallback getEncryptionKeyCallback}) async { required GetEncryptionKeyCallback getEncryptionKeyCallback,
required CancelRequest cancelRequest,
}) async {
final pool = DHTRecordPool.instance; final pool = DHTRecordPool.instance;
// Get contact request inbox from invitation // Get contact request inbox from invitation
@ -245,15 +248,18 @@ class ContactInvitationListCubit
contactRequestInboxKey) != contactRequestInboxKey) !=
-1; -1;
await (await pool.openRecordRead(contactRequestInboxKey, await (await pool
debugName: 'ContactInvitationListCubit::validateInvitation::' .openRecordRead(contactRequestInboxKey,
'ContactRequestInbox', debugName: 'ContactInvitationListCubit::validateInvitation::'
parent: pool.getParentRecordKey(contactRequestInboxKey) ?? 'ContactRequestInbox',
_accountInfo.accountRecordKey)) parent: pool.getParentRecordKey(contactRequestInboxKey) ??
_accountInfo.accountRecordKey)
.withCancel(cancelRequest))
.maybeDeleteScope(!isSelf, (contactRequestInbox) async { .maybeDeleteScope(!isSelf, (contactRequestInbox) async {
// //
final contactRequest = await contactRequestInbox final contactRequest = await contactRequestInbox
.getProtobuf(proto.ContactRequest.fromBuffer); .getProtobuf(proto.ContactRequest.fromBuffer)
.withCancel(cancelRequest);
final cs = await pool.veilid.getCryptoSystem(contactRequestInboxKey.kind); final cs = await pool.veilid.getCryptoSystem(contactRequestInboxKey.kind);
@ -281,7 +287,8 @@ class ContactInvitationListCubit
// Fetch the account master // Fetch the account master
final contactSuperIdentity = await SuperIdentity.open( final contactSuperIdentity = await SuperIdentity.open(
superRecordKey: contactSuperIdentityRecordKey); superRecordKey: contactSuperIdentityRecordKey)
.withCancel(cancelRequest);
// Verify // Verify
final idcs = await contactSuperIdentity.currentInstance.cryptoSystem; final idcs = await contactSuperIdentity.currentInstance.cryptoSystem;

View File

@ -11,6 +11,7 @@ import 'package:provider/provider.dart';
import 'package:qr_flutter/qr_flutter.dart'; import 'package:qr_flutter/qr_flutter.dart';
import 'package:veilid_support/veilid_support.dart'; import 'package:veilid_support/veilid_support.dart';
import '../../account_manager/account_manager.dart';
import '../../notifications/notifications.dart'; import '../../notifications/notifications.dart';
import '../../proto/proto.dart' as proto; import '../../proto/proto.dart' as proto;
import '../../theme/theme.dart'; import '../../theme/theme.dart';
@ -20,17 +21,20 @@ class ContactInvitationDisplayDialog extends StatelessWidget {
const ContactInvitationDisplayDialog._({ const ContactInvitationDisplayDialog._({
required this.locator, required this.locator,
required this.message, required this.message,
required this.fingerprint,
}); });
final Locator locator; final Locator locator;
final String message; final String message;
final String fingerprint;
@override @override
void debugFillProperties(DiagnosticPropertiesBuilder properties) { void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties); super.debugFillProperties(properties);
properties properties
..add(StringProperty('message', message)) ..add(StringProperty('message', message))
..add(DiagnosticsProperty<Locator>('locator', locator)); ..add(DiagnosticsProperty<Locator>('locator', locator))
..add(StringProperty('fingerprint', fingerprint));
} }
String makeTextInvite(String message, Uint8List data) { String makeTextInvite(String message, Uint8List data) {
@ -38,10 +42,12 @@ class ContactInvitationDisplayDialog extends StatelessWidget {
base64UrlNoPadEncode(data), '\n', 40, base64UrlNoPadEncode(data), '\n', 40,
repeat: true); repeat: true);
final msg = message.isNotEmpty ? '$message\n' : ''; final msg = message.isNotEmpty ? '$message\n' : '';
return '$msg' return '$msg'
'--- BEGIN VEILIDCHAT CONTACT INVITE ----\n' '--- BEGIN VEILIDCHAT CONTACT INVITE ----\n'
'$invite\n' '$invite\n'
'---- END VEILIDCHAT CONTACT INVITE -----\n'; '---- END VEILIDCHAT CONTACT INVITE -----\n'
'Fingerprint:\n$fingerprint\n';
} }
@override @override
@ -97,18 +103,27 @@ class ContactInvitationDisplayDialog extends StatelessWidget {
.copyWith(color: Colors.black))) .copyWith(color: Colors.black)))
.paddingAll(8), .paddingAll(8),
FittedBox( FittedBox(
child: QrImageView.withQr( child: QrImageView.withQr(
size: 300, size: 300,
qr: QrCode.fromUint8List( qr: QrCode.fromUint8List(
data: data.$1, data: data.$1,
errorCorrectLevel: errorCorrectLevel:
QrErrorCorrectLevel.L))) QrErrorCorrectLevel.L)),
.expanded(), ).expanded(),
Text(message, Text(message,
softWrap: true, softWrap: true,
style: textTheme.labelLarge! style: textTheme.labelLarge!
.copyWith(color: Colors.black)) .copyWith(color: Colors.black))
.paddingAll(8), .paddingAll(8),
Text(
'${translate('create_invitation_dialog.fingerprint')}\n'
'$fingerprint',
softWrap: true,
textAlign: TextAlign.center,
style: textTheme.labelSmall!.copyWith(
color: Colors.black,
fontFamily: 'Source Code Pro'))
.paddingAll(2),
ElevatedButton.icon( ElevatedButton.icon(
icon: const Icon(Icons.copy), icon: const Icon(Icons.copy),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
@ -129,11 +144,15 @@ class ContactInvitationDisplayDialog extends StatelessWidget {
error: errorPage))))); error: errorPage)))));
} }
static Future<void> show( static Future<void> show({
{required BuildContext context, required BuildContext context,
required Locator locator, required Locator locator,
required InvitationGeneratorCubit Function(BuildContext) create, required InvitationGeneratorCubit Function(BuildContext) create,
required String message}) async { required String message,
}) async {
final fingerprint =
locator<AccountInfoCubit>().state.identityPublicKey.toString();
await showPopControlDialog<void>( await showPopControlDialog<void>(
context: context, context: context,
builder: (context) => BlocProvider( builder: (context) => BlocProvider(
@ -141,6 +160,7 @@ class ContactInvitationDisplayDialog extends StatelessWidget {
child: ContactInvitationDisplayDialog._( child: ContactInvitationDisplayDialog._(
locator: locator, locator: locator,
message: message, message: message,
fingerprint: fingerprint,
))); )));
} }
} }

View File

@ -45,7 +45,7 @@ class ContactInvitationItemWidget extends StatelessWidget {
title: contactInvitationRecord.message.isEmpty title: contactInvitationRecord.message.isEmpty
? translate('contact_list.invitation') ? translate('contact_list.invitation')
: contactInvitationRecord.message, : contactInvitationRecord.message,
icon: Icons.person_add, leading: const Icon(Icons.person_add),
onTap: () async { onTap: () async {
if (!context.mounted) { if (!context.mounted) {
return; return;

View File

@ -61,7 +61,7 @@ class ContactInvitationListWidgetState
}); });
_controller.animateTo(_expanded ? 1 : 0); _controller.animateTo(_expanded ? 1 : 0);
}, },
title: translate('contacts_page.invitations'), title: translate('contacts_dialog.invitations'),
sliver: SliverList.builder( sliver: SliverList.builder(
itemCount: widget.contactInvitationRecordList.length, itemCount: widget.contactInvitationRecordList.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {

View File

@ -37,8 +37,7 @@ class CreateInvitationDialog extends StatefulWidget {
} }
class CreateInvitationDialogState extends State<CreateInvitationDialog> { class CreateInvitationDialogState extends State<CreateInvitationDialog> {
final _messageTextController = TextEditingController( late final TextEditingController _messageTextController;
text: translate('create_invitation_dialog.connect_with_me'));
EncryptionKeyType _encryptionKeyType = EncryptionKeyType.none; EncryptionKeyType _encryptionKeyType = EncryptionKeyType.none;
String _encryptionKey = ''; String _encryptionKey = '';
@ -46,6 +45,12 @@ class CreateInvitationDialogState extends State<CreateInvitationDialog> {
@override @override
void initState() { void initState() {
final accountInfo = widget.locator<AccountRecordCubit>().state;
final name = accountInfo.asData?.value.profile.name ??
translate('create_invitation_dialog.me');
_messageTextController = TextEditingController(
text: translate('create_invitation_dialog.connect_with_me',
args: {'name': name}));
super.initState(); super.initState();
} }
@ -152,13 +157,13 @@ class CreateInvitationDialogState extends State<CreateInvitationDialog> {
message: _messageTextController.text, message: _messageTextController.text,
expiration: _expiration); expiration: _expiration);
navigator.pop();
await ContactInvitationDisplayDialog.show( await ContactInvitationDisplayDialog.show(
context: context, context: context,
locator: widget.locator, locator: widget.locator,
message: _messageTextController.text, message: _messageTextController.text,
create: (context) => InvitationGeneratorCubit(generator)); create: (context) => InvitationGeneratorCubit(generator));
navigator.pop();
} }
@override @override
@ -198,34 +203,37 @@ class CreateInvitationDialogState extends State<CreateInvitationDialog> {
Text(translate('create_invitation_dialog.protect_this_invitation'), Text(translate('create_invitation_dialog.protect_this_invitation'),
style: textTheme.labelLarge) style: textTheme.labelLarge)
.paddingAll(8), .paddingAll(8),
Wrap(spacing: 5, children: [ Wrap(
ChoiceChip( alignment: WrapAlignment.center,
label: Text(translate('create_invitation_dialog.unlocked')), runAlignment: WrapAlignment.center,
selected: _encryptionKeyType == EncryptionKeyType.none, runSpacing: 8,
onSelected: _onNoneEncryptionSelected, spacing: 8,
), children: [
ChoiceChip( ChoiceChip(
label: Text(translate('create_invitation_dialog.pin')), label: Text(translate('create_invitation_dialog.unlocked')),
selected: _encryptionKeyType == EncryptionKeyType.pin, selected: _encryptionKeyType == EncryptionKeyType.none,
onSelected: _onPinEncryptionSelected, onSelected: _onNoneEncryptionSelected,
), ),
ChoiceChip( ChoiceChip(
label: Text(translate('create_invitation_dialog.password')), label: Text(translate('create_invitation_dialog.pin')),
selected: _encryptionKeyType == EncryptionKeyType.password, selected: _encryptionKeyType == EncryptionKeyType.pin,
onSelected: _onPasswordEncryptionSelected, onSelected: _onPinEncryptionSelected,
) ),
]).paddingAll(8), ChoiceChip(
label: Text(translate('create_invitation_dialog.password')),
selected: _encryptionKeyType == EncryptionKeyType.password,
onSelected: _onPasswordEncryptionSelected,
)
]).paddingAll(8).toCenter(),
Container( Container(
width: double.infinity,
height: 60,
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
child: ElevatedButton( child: ElevatedButton(
onPressed: _onGenerateButtonPressed, onPressed: _onGenerateButtonPressed,
child: Text( child: Text(
translate('create_invitation_dialog.generate'), translate('create_invitation_dialog.generate'),
), ).paddingAll(16),
), ),
), ).toCenter(),
Text(translate('create_invitation_dialog.note')).paddingAll(8), Text(translate('create_invitation_dialog.note')).paddingAll(8),
Text( Text(
translate('create_invitation_dialog.note_text'), translate('create_invitation_dialog.note_text'),

View File

@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:async_tools/async_tools.dart';
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';
@ -61,17 +62,19 @@ class InvitationDialog extends StatefulWidget {
} }
class InvitationDialogState extends State<InvitationDialog> { class InvitationDialogState extends State<InvitationDialog> {
ValidContactInvitation? _validInvitation;
bool _isValidating = false;
bool _isAccepting = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
} }
bool get isValidating => _isValidating; Future<void> _onCancel() async {
bool get isAccepting => _isAccepting; final navigator = Navigator.of(context);
_cancelRequest.cancel();
setState(() {
_isAccepting = false;
});
navigator.pop();
}
Future<void> _onAccept() async { Future<void> _onAccept() async {
final navigator = Navigator.of(context); final navigator = Navigator.of(context);
@ -153,6 +156,7 @@ class InvitationDialogState extends State<InvitationDialog> {
final validatedContactInvitation = final validatedContactInvitation =
await contactInvitationListCubit.validateInvitation( await contactInvitationListCubit.validateInvitation(
inviteData: inviteData, inviteData: inviteData,
cancelRequest: _cancelRequest,
getEncryptionKeyCallback: getEncryptionKeyCallback:
(cs, encryptionKeyType, encryptedSecret) async { (cs, encryptionKeyType, encryptedSecret) async {
String encryptionKey; String encryptionKey;
@ -234,6 +238,9 @@ class InvitationDialogState extends State<InvitationDialog> {
late final String errorText; late final String errorText;
if (e is VeilidAPIExceptionTryAgain) { if (e is VeilidAPIExceptionTryAgain) {
errorText = translate('invitation_dialog.try_again_online'); errorText = translate('invitation_dialog.try_again_online');
}
if (e is VeilidAPIExceptionKeyNotFound) {
errorText = translate('invitation_dialog.key_not_found');
} else { } else {
errorText = translate('invitation_dialog.invalid_invitation'); errorText = translate('invitation_dialog.invalid_invitation');
} }
@ -245,6 +252,12 @@ class InvitationDialogState extends State<InvitationDialog> {
_validInvitation = null; _validInvitation = null;
widget.onValidationFailed(); widget.onValidationFailed();
}); });
} on CancelException {
setState(() {
_isValidating = false;
_validInvitation = null;
widget.onValidationCancelled();
});
} on Exception catch (e) { } on Exception catch (e) {
log.debug('exception: $e', e); log.debug('exception: $e', e);
setState(() { setState(() {
@ -264,6 +277,11 @@ class InvitationDialogState extends State<InvitationDialog> {
Text(translate('invitation_dialog.validating')) Text(translate('invitation_dialog.validating'))
.paddingLTRB(0, 0, 0, 16), .paddingLTRB(0, 0, 0, 16),
buildProgressIndicator().paddingAll(16), buildProgressIndicator().paddingAll(16),
ElevatedButton.icon(
icon: const Icon(Icons.cancel),
label: Text(translate('button.cancel')),
onPressed: _onCancel,
).paddingAll(16),
]).toCenter(), ]).toCenter(),
if (_validInvitation == null && if (_validInvitation == null &&
!_isValidating && !_isValidating &&
@ -315,13 +333,25 @@ class InvitationDialogState extends State<InvitationDialog> {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: _isAccepting children: _isAccepting
? [buildProgressIndicator().paddingAll(16)] ? [
buildProgressIndicator().paddingAll(16),
]
: _buildPreAccept()), : _buildPreAccept()),
), ),
); );
return PopControl(dismissible: dismissible, child: dialog); return PopControl(dismissible: dismissible, child: dialog);
} }
////////////////////////////////////////////////////////////////////////////
ValidContactInvitation? _validInvitation;
bool _isValidating = false;
bool _isAccepting = false;
final _cancelRequest = CancelRequest();
bool get isValidating => _isValidating;
bool get isAccepting => _isAccepting;
@override @override
void debugFillProperties(DiagnosticPropertiesBuilder properties) { void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties); super.debugFillProperties(properties);

View File

@ -71,7 +71,43 @@ class ContactListCubit extends DHTShortArrayCubit<proto.Contact> {
final updated = await writer.tryWriteItemProtobuf( final updated = await writer.tryWriteItemProtobuf(
proto.Contact.fromBuffer, pos, newContact); proto.Contact.fromBuffer, pos, newContact);
if (!updated) { if (!updated) {
throw DHTExceptionOutdated(); throw const DHTExceptionOutdated();
}
break;
}
}
});
}
Future<void> updateContactFields({
required TypedKey localConversationRecordKey,
String? nickname,
String? notes,
bool? showAvailability,
}) async {
// Update contact's locally-modifiable fields
await operateWriteEventual((writer) async {
for (var pos = 0; pos < writer.length; pos++) {
final c = await writer.getProtobuf(proto.Contact.fromBuffer, pos);
if (c != null &&
c.localConversationRecordKey.toVeilid() ==
localConversationRecordKey) {
final newContact = c.deepCopy();
if (nickname != null) {
newContact.nickname = nickname;
}
if (notes != null) {
newContact.notes = notes;
}
if (showAvailability != null) {
newContact.showAvailability = showAvailability;
}
final updated = await writer.tryWriteItemProtobuf(
proto.Contact.fromBuffer, pos, newContact);
if (!updated) {
throw const DHTExceptionOutdated();
} }
break; break;
} }

View File

@ -0,0 +1,90 @@
import 'package:awesome_extensions/awesome_extensions.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_translate/flutter_translate.dart';
import '../../proto/proto.dart' as proto;
class AvailabilityWidget extends StatelessWidget {
const AvailabilityWidget(
{required this.availability,
this.vertical = true,
this.iconSize = 32,
super.key});
static Widget availabilityIcon(proto.Availability availability,
{double size = 32}) {
late final Widget iconData;
switch (availability) {
case proto.Availability.AVAILABILITY_AWAY:
iconData =
ImageIcon(const AssetImage('assets/images/toilet.png'), size: size);
case proto.Availability.AVAILABILITY_BUSY:
iconData = Icon(Icons.event_busy, size: size);
case proto.Availability.AVAILABILITY_FREE:
iconData = Icon(Icons.event_available, size: size);
case proto.Availability.AVAILABILITY_OFFLINE:
iconData = Icon(Icons.cloud_off, size: size);
case proto.Availability.AVAILABILITY_UNSPECIFIED:
iconData = Icon(Icons.question_mark, size: size);
}
return iconData;
}
static String availabilityName(proto.Availability availability) {
late final String name;
switch (availability) {
case proto.Availability.AVAILABILITY_AWAY:
name = translate('availability.away');
case proto.Availability.AVAILABILITY_BUSY:
name = translate('availability.busy');
case proto.Availability.AVAILABILITY_FREE:
name = translate('availability.free');
case proto.Availability.AVAILABILITY_OFFLINE:
name = translate('availability.offline');
case proto.Availability.AVAILABILITY_UNSPECIFIED:
name = translate('availability.unspecified');
}
return name;
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final textTheme = theme.textTheme;
// final scale = theme.extension<ScaleScheme>()!;
// final scaleConfig = theme.extension<ScaleConfig>()!;
final name = availabilityName(availability);
final icon = availabilityIcon(availability, size: iconSize);
return vertical
? Column(
mainAxisSize: MainAxisSize.min,
//mainAxisAlignment: MainAxisAlignment.center,
children: [
icon,
Text(name, style: textTheme.labelSmall).paddingLTRB(0, 0, 0, 0)
])
: Row(mainAxisSize: MainAxisSize.min, children: [
icon,
Text(name, style: textTheme.labelSmall).paddingLTRB(8, 0, 0, 0)
]);
}
////////////////////////////////////////////////////////////////////////////
final proto.Availability availability;
final bool vertical;
final double iconSize;
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties
..add(
DiagnosticsProperty<proto.Availability>('availability', availability))
..add(DiagnosticsProperty<bool>('vertical', vertical))
..add(DoubleProperty('iconSize', iconSize));
}
}

View File

@ -0,0 +1,41 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../proto/proto.dart' as proto;
import '../contacts.dart';
class ContactDetailsWidget extends StatefulWidget {
const ContactDetailsWidget({required this.contact, super.key});
final proto.Contact contact;
@override
State<ContactDetailsWidget> createState() => _ContactDetailsWidgetState();
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<proto.Contact>('contact', contact));
}
}
class _ContactDetailsWidgetState extends State<ContactDetailsWidget>
with SingleTickerProviderStateMixin {
@override
Widget build(BuildContext context) => SingleChildScrollView(
child: EditContactForm(
formKey: GlobalKey(),
contact: widget.contact,
onSubmit: (fbs) async {
final contactList = context.read<ContactListCubit>();
await contactList.updateContactFields(
localConversationRecordKey:
widget.contact.localConversationRecordKey.toVeilid(),
nickname: fbs.currentState
?.value[EditContactForm.formFieldNickname] as String,
notes: fbs.currentState?.value[EditContactForm.formFieldNotes]
as String,
showAvailability: fbs.currentState
?.value[EditContactForm.formFieldShowAvailability] as bool);
}));
}

View File

@ -1,87 +1,85 @@
import 'package:async_tools/async_tools.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_translate/flutter_translate.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 '../../proto/proto.dart' as proto;
import '../../theme/theme.dart'; import '../../theme/theme.dart';
import '../contacts.dart';
const _kOnTap = 'onTap';
const _kOnDelete = 'onDelete';
class ContactItemWidget extends StatelessWidget { class ContactItemWidget extends StatelessWidget {
const ContactItemWidget( const ContactItemWidget(
{required proto.Contact contact, required bool disabled, super.key}) {required proto.Contact contact,
required bool disabled,
required bool selected,
Future<void> Function(proto.Contact)? onTap,
Future<void> Function(proto.Contact)? onDoubleTap,
Future<void> Function(proto.Contact)? onDelete,
super.key})
: _disabled = disabled, : _disabled = disabled,
_contact = contact; _selected = selected,
_contact = contact,
_onTap = onTap,
_onDoubleTap = onDoubleTap,
_onDelete = onDelete;
@override @override
// ignore: prefer_expression_function_bodies // ignore: prefer_expression_function_bodies
Widget build( Widget build(
BuildContext context, BuildContext context,
) { ) {
final localConversationRecordKey = final theme = Theme.of(context);
_contact.localConversationRecordKey.toVeilid(); final scale = theme.extension<ScaleScheme>()!;
final scaleConfig = theme.extension<ScaleConfig>()!;
const selected = false; // xxx: eventually when we have selectable contacts: final name = _contact.nameOrNickname;
// activeContactCubit.state == localConversationRecordKey; final title = _contact.displayName;
final subtitle = _contact.profile.status;
final tileDisabled = _disabled || context.watch<ContactListCubit>().isBusy; final avatar = AvatarWidget(
name: name,
late final String title; size: 34,
late final String subtitle; borderColor: _disabled
if (_contact.nickname.isNotEmpty) { ? scale.grayScale.primaryText
title = _contact.nickname; : scale.primaryScale.primaryText,
if (_contact.profile.pronouns.isNotEmpty) { foregroundColor: _disabled
subtitle = '${_contact.profile.name} (${_contact.profile.pronouns})'; ? scale.grayScale.primaryText
} else { : scale.primaryScale.primaryText,
subtitle = _contact.profile.name; backgroundColor:
} _disabled ? scale.grayScale.primary : scale.primaryScale.primary,
} else { scaleConfig: scaleConfig,
title = _contact.profile.name; textStyle: theme.textTheme.titleLarge!,
if (_contact.profile.pronouns.isNotEmpty) { );
subtitle = '(${_contact.profile.pronouns})';
} else {
subtitle = '';
}
}
return SliderTile( return SliderTile(
key: ObjectKey(_contact), key: ObjectKey(_contact),
disabled: tileDisabled, disabled: _disabled,
selected: selected, selected: _selected,
tileScale: ScaleKind.primary, tileScale: ScaleKind.primary,
title: title, title: title,
subtitle: subtitle, subtitle: subtitle,
icon: Icons.person, leading: avatar,
onTap: () async { onDoubleTap: _onDoubleTap == null
// Start a chat ? null
final chatListCubit = context.read<ChatListCubit>(); : () => singleFuture<void>((this, _kOnTap), () async {
await _onDoubleTap(_contact);
await chatListCubit.getOrCreateChatSingleContact(contact: _contact); }),
// Click over to chats onTap: _onTap == null
if (context.mounted) { ? null
await MainPager.of(context) : () => singleFuture<void>((this, _kOnTap), () async {
?.pageController await _onTap(_contact);
.animateToPage(1, duration: 250.ms, curve: Curves.easeInOut); }),
}
},
endActions: [ endActions: [
SliderTileAction( if (_onDelete != null)
SliderTileAction(
icon: Icons.delete, icon: Icons.delete,
label: translate('button.delete'), label: translate('button.delete'),
actionScale: ScaleKind.tertiary, actionScale: ScaleKind.tertiary,
onPressed: (context) async { onPressed: (_context) =>
final contactListCubit = context.read<ContactListCubit>(); singleFuture<void>((this, _kOnDelete), () async {
final chatListCubit = context.read<ChatListCubit>(); await _onDelete(_contact);
}),
// Delete the contact itself ),
await contactListCubit.deleteContact(
localConversationRecordKey: localConversationRecordKey);
// Remove any chats for this contact
await chatListCubit.deleteChat(
localConversationRecordKey: localConversationRecordKey);
})
], ],
); );
} }
@ -90,4 +88,8 @@ class ContactItemWidget extends StatelessWidget {
final proto.Contact _contact; final proto.Contact _contact;
final bool _disabled; final bool _disabled;
final bool _selected;
final Future<void> Function(proto.Contact contact)? _onTap;
final Future<void> Function(proto.Contact contact)? _onDoubleTap;
final Future<void> Function(proto.Contact contact)? _onDelete;
} }

View File

@ -1,86 +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_translate/flutter_translate.dart';
import 'package:searchable_listview/searchable_listview.dart';
import '../../proto/proto.dart' as proto;
import '../../theme/theme.dart';
import 'contact_item_widget.dart';
import 'empty_contact_list_widget.dart';
class ContactListWidget extends StatefulWidget {
const ContactListWidget(
{required this.contactList, required this.disabled, super.key});
final IList<proto.Contact>? contactList;
final bool disabled;
@override
State<ContactListWidget> createState() => _ContactListWidgetState();
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties
..add(IterableProperty<proto.Contact>('contactList', contactList))
..add(DiagnosticsProperty<bool>('disabled', disabled));
}
}
class _ContactListWidgetState extends State<ContactListWidget>
with SingleTickerProviderStateMixin {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
//final textTheme = theme.textTheme;
final scale = theme.extension<ScaleScheme>()!;
final scaleConfig = theme.extension<ScaleConfig>()!;
return SliverLayoutBuilder(
builder: (context, constraints) => styledHeaderSliver(
context: context,
backgroundColor: scaleConfig.preferBorders
? scale.primaryScale.subtleBackground
: scale.primaryScale.subtleBorder,
title: translate('contacts_page.contacts'),
sliver: SliverFillRemaining(
child: SearchableList<proto.Contact>.sliver(
initialList: widget.contactList == null
? []
: widget.contactList!.toList(),
itemBuilder: (c) =>
ContactItemWidget(contact: c, disabled: widget.disabled)
.paddingLTRB(0, 4, 0, 0),
filter: (value) {
final lowerValue = value.toLowerCase();
if (widget.contactList == null) {
return [];
}
return widget.contactList!
.where((element) =>
element.nickname.toLowerCase().contains(lowerValue) ||
element.profile.name
.toLowerCase()
.contains(lowerValue) ||
element.profile.pronouns
.toLowerCase()
.contains(lowerValue))
.toList();
},
searchFieldHeight: 40,
spaceBetweenSearchAndList: 4,
emptyWidget: widget.contactList == null
? waitingPage(
text: translate('contacts_page.loading_contacts'))
: const EmptyContactListWidget(),
defaultSuffixIconColor: scale.primaryScale.border,
closeKeyboardWhenScrolling: true,
searchFieldEnabled: widget.contactList != null,
inputDecoration: InputDecoration(
labelText: translate('contact_list.search'),
),
),
)));
}
}

View File

@ -0,0 +1,326 @@
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_bloc/flutter_bloc.dart';
import 'package:flutter_translate/flutter_translate.dart';
import 'package:searchable_listview/searchable_listview.dart';
import 'package:star_menu/star_menu.dart';
import 'package:veilid_support/veilid_support.dart';
import '../../chat_list/chat_list.dart';
import '../../contact_invitation/contact_invitation.dart';
import '../../proto/proto.dart' as proto;
import '../../theme/theme.dart';
import '../cubits/cubits.dart';
import 'contact_item_widget.dart';
import 'empty_contact_list_widget.dart';
enum ContactsBrowserElementKind {
invitation,
contact,
}
class ContactsBrowserElement {
ContactsBrowserElement.invitation(proto.ContactInvitationRecord i)
: kind = ContactsBrowserElementKind.invitation,
contact = null,
invitation = i;
ContactsBrowserElement.contact(proto.Contact c)
: kind = ContactsBrowserElementKind.contact,
invitation = null,
contact = c;
final ContactsBrowserElementKind kind;
final proto.ContactInvitationRecord? invitation;
final proto.Contact? contact;
}
class ContactsBrowser extends StatefulWidget {
const ContactsBrowser(
{required this.onContactSelected,
required this.onChatStarted,
this.selectedContactRecordKey,
super.key});
@override
State<ContactsBrowser> createState() => _ContactsBrowserState();
final Future<void> Function(proto.Contact? contact) onContactSelected;
final Future<void> Function(proto.Contact contact) onChatStarted;
final TypedKey? selectedContactRecordKey;
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties
..add(DiagnosticsProperty<TypedKey?>(
'selectedContactRecordKey', selectedContactRecordKey))
..add(
ObjectFlagProperty<Future<void> Function(proto.Contact? contact)>.has(
'onContactSelected', onContactSelected))
..add(
ObjectFlagProperty<Future<void> Function(proto.Contact contact)>.has(
'onChatStarted', onChatStarted));
}
}
class _ContactsBrowserState extends State<ContactsBrowser>
with SingleTickerProviderStateMixin {
Widget buildInvitationBar(BuildContext context) {
final theme = Theme.of(context);
final textTheme = theme.textTheme;
final scale = theme.extension<ScaleScheme>()!;
final scaleConfig = theme.extension<ScaleConfig>()!;
final menuIconColor = scaleConfig.preferBorders
? scale.primaryScale.hoverBorder
: scale.primaryScale.borderText;
final menuBackgroundColor = scaleConfig.preferBorders
? scale.primaryScale.elementBackground
: scale.primaryScale.border;
// final menuHoverColor = scaleConfig.preferBorders
// ? scale.primaryScale.hoverElementBackground
// : scale.primaryScale.hoverBorder;
final menuBorderColor = scale.primaryScale.hoverBorder;
final menuParams = StarMenuParameters(
shape: MenuShape.grid,
checkItemsScreenBoundaries: true,
centerOffset: const Offset(0, 64),
backgroundParams:
BackgroundParams(backgroundColor: theme.shadowColor.withAlpha(128)),
boundaryBackground: BoundaryBackground(
color: menuBackgroundColor,
decoration: ShapeDecoration(
color: menuBackgroundColor,
shape: RoundedRectangleBorder(
side: scaleConfig.useVisualIndicators
? BorderSide(
width: 2, color: menuBorderColor, strokeAlign: 0)
: BorderSide.none,
borderRadius: BorderRadius.circular(
8 * scaleConfig.borderRadiusScale)))));
final receiveInviteMenuItems = [
Column(mainAxisSize: MainAxisSize.min, children: [
IconButton(
onPressed: () async {
_receiveInviteMenuController.closeMenu!();
await ScanInvitationDialog.show(context);
},
iconSize: 32,
icon: Icon(
Icons.qr_code_scanner,
size: 32,
color: menuIconColor,
),
),
Text(translate('add_contact_sheet.scan_invite'),
maxLines: 2,
textAlign: TextAlign.center,
style: textTheme.labelSmall!.copyWith(color: menuIconColor))
]).paddingAll(4),
Column(mainAxisSize: MainAxisSize.min, children: [
IconButton(
onPressed: () async {
_receiveInviteMenuController.closeMenu!();
await PasteInvitationDialog.show(context);
},
iconSize: 32,
icon: Icon(
Icons.paste,
size: 32,
color: menuIconColor,
),
),
Text(translate('add_contact_sheet.paste_invite'),
maxLines: 2,
textAlign: TextAlign.center,
style: textTheme.labelSmall!.copyWith(color: menuIconColor))
]).paddingAll(4)
];
return Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [
Column(mainAxisSize: MainAxisSize.min, children: [
IconButton(
onPressed: () async {
await CreateInvitationDialog.show(context);
},
iconSize: 32,
icon: const Icon(Icons.contact_page),
color: scale.primaryScale.hoverBorder,
),
Text(translate('add_contact_sheet.create_invite'),
maxLines: 2,
textAlign: TextAlign.center,
style: textTheme.labelSmall!
.copyWith(color: scale.primaryScale.hoverBorder))
]),
StarMenu(
items: receiveInviteMenuItems,
onItemTapped: (_index, controller) {
controller.closeMenu!();
},
controller: _receiveInviteMenuController,
params: menuParams,
child: Column(mainAxisSize: MainAxisSize.min, children: [
IconButton(
onPressed: () {},
iconSize: 32,
icon: ImageIcon(
const AssetImage('assets/images/handshake.png'),
size: 32,
color: scale.primaryScale.hoverBorder,
)),
Text(translate('add_contact_sheet.receive_invite'),
maxLines: 2,
textAlign: TextAlign.center,
style: textTheme.labelSmall!
.copyWith(color: scale.primaryScale.hoverBorder))
]),
),
]).paddingAll(16);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final textTheme = theme.textTheme;
final scale = theme.extension<ScaleScheme>()!;
//final scaleConfig = theme.extension<ScaleConfig>()!;
final cilState = context.watch<ContactInvitationListCubit>().state;
final cilBusy = cilState.busy;
final contactInvitationRecordList =
cilState.state.asData?.value.map((x) => x.value).toIList() ??
const IListConst([]);
final ciState = context.watch<ContactListCubit>().state;
final ciBusy = ciState.busy;
final contactList =
ciState.state.asData?.value.map((x) => x.value).toIList();
final expansionListData =
<ContactsBrowserElementKind, List<ContactsBrowserElement>>{};
if (contactInvitationRecordList.isNotEmpty) {
expansionListData[ContactsBrowserElementKind.invitation] =
contactInvitationRecordList
.toList()
.map(ContactsBrowserElement.invitation)
.toList();
}
if (contactList != null) {
expansionListData[ContactsBrowserElementKind.contact] =
contactList.toList().map(ContactsBrowserElement.contact).toList();
}
return Column(children: [
buildInvitationBar(context),
SearchableList<ContactsBrowserElement>.expansion(
expansionListData: expansionListData,
expansionTitleBuilder: (k) {
final kind = k as ContactsBrowserElementKind;
late final String title;
switch (kind) {
case ContactsBrowserElementKind.contact:
title = translate('contacts_dialog.contacts');
case ContactsBrowserElementKind.invitation:
title = translate('contacts_dialog.invitations');
}
return Center(
child: Text(title, style: textTheme.titleSmall),
);
},
expansionInitiallyExpanded: (k) => true,
expansionListBuilder: (_index, element) {
switch (element.kind) {
case ContactsBrowserElementKind.contact:
final contact = element.contact!;
return ContactItemWidget(
contact: contact,
selected: widget.selectedContactRecordKey ==
contact.localConversationRecordKey.toVeilid(),
disabled: ciBusy,
onTap: _onTapContact,
onDoubleTap: _onStartChat,
onDelete: _onDeleteContact)
.paddingLTRB(0, 4, 0, 0);
case ContactsBrowserElementKind.invitation:
final invitation = element.invitation!;
return ContactInvitationItemWidget(
contactInvitationRecord: invitation, disabled: cilBusy)
.paddingLTRB(0, 4, 0, 0);
}
},
filterExpansionData: (value) {
final lowerValue = value.toLowerCase();
final filteredMap = {
for (final entry in expansionListData.entries)
entry.key: (expansionListData[entry.key] ?? []).where((element) {
switch (element.kind) {
case ContactsBrowserElementKind.contact:
final contact = element.contact!;
return contact.nickname
.toLowerCase()
.contains(lowerValue) ||
contact.profile.name
.toLowerCase()
.contains(lowerValue) ||
contact.profile.pronouns
.toLowerCase()
.contains(lowerValue);
case ContactsBrowserElementKind.invitation:
final invitation = element.invitation!;
return invitation.message
.toLowerCase()
.contains(lowerValue);
}
}).toList()
};
return filteredMap;
},
hideEmptyExpansionItems: true,
searchFieldHeight: 40,
listViewPadding: const EdgeInsets.all(4),
spaceBetweenSearchAndList: 4,
emptyWidget: contactList == null
? waitingPage(text: translate('contact_list.loading_contacts'))
: const EmptyContactListWidget(),
defaultSuffixIconColor: scale.primaryScale.border,
closeKeyboardWhenScrolling: true,
searchFieldEnabled: contactList != null,
inputDecoration:
InputDecoration(labelText: translate('contact_list.search')),
).expanded()
]);
}
Future<void> _onTapContact(proto.Contact contact) async {
await widget.onContactSelected(contact);
}
Future<void> _onStartChat(proto.Contact contact) async {
await widget.onChatStarted(contact);
}
Future<void> _onDeleteContact(proto.Contact contact) async {
final localConversationRecordKey =
contact.localConversationRecordKey.toVeilid();
final contactListCubit = context.read<ContactListCubit>();
final chatListCubit = context.read<ChatListCubit>();
// Delete the contact itself
await contactListCubit.deleteContact(
localConversationRecordKey: localConversationRecordKey);
// Remove any chats for this contact
await chatListCubit.deleteChat(
localConversationRecordKey: localConversationRecordKey);
}
////////////////////////////////////////////////////////////////////////////
final _receiveInviteMenuController = StarMenuController();
}

View File

@ -0,0 +1,136 @@
import 'package:awesome_extensions/awesome_extensions.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_translate/flutter_translate.dart';
import 'package:provider/provider.dart';
import '../../chat/chat.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 ContactsDialog extends StatefulWidget {
const ContactsDialog._({required this.modalContext});
@override
State<ContactsDialog> createState() => _ContactsDialogState();
static Future<void> show(BuildContext modalContext) async {
await showDialog<void>(
context: modalContext,
barrierDismissible: false,
useRootNavigator: false,
builder: (context) => ContactsDialog._(modalContext: modalContext));
}
final BuildContext modalContext;
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties
.add(DiagnosticsProperty<BuildContext>('modalContext', modalContext));
}
}
class _ContactsDialogState extends State<ContactsDialog> {
@override
void initState() {
super.initState();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
// final textTheme = theme.textTheme;
final scale = theme.extension<ScaleScheme>()!;
// final scaleConfig = theme.extension<ScaleConfig>()!;
final enableSplit = !isMobileWidth(context);
final enableLeft = enableSplit || _selectedContact == null;
final enableRight = enableSplit || _selectedContact != null;
return SizedBox(
width: MediaQuery.of(context).size.width,
child: StyledScaffold(
appBar: DefaultAppBar(
title: Text(!enableSplit && enableRight
? translate('contacts_dialog.edit_contact')
: translate('contacts_dialog.contacts')),
leading: Navigator.canPop(context)
? IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
if (!enableSplit && enableRight) {
setState(() {
_selectedContact = null;
});
} else {
Navigator.pop(context);
}
},
)
: null,
actions: [
if (_selectedContact != null)
IconButton(
icon: const Icon(Icons.chat_bubble),
tooltip: translate('contacts_dialog.new_chat'),
onPressed: () async {
await onChatStarted(_selectedContact!);
})
]),
body: LayoutBuilder(builder: (context, constraint) {
final maxWidth = constraint.maxWidth;
return Row(children: [
Offstage(
offstage: !enableLeft,
child: SizedBox(
width: enableLeft && !enableRight
? maxWidth
: (maxWidth / 3).clamp(200, 500),
child: DecoratedBox(
decoration: BoxDecoration(
color: scale.primaryScale.subtleBackground),
child: ContactsBrowser(
selectedContactRecordKey: _selectedContact
?.localConversationRecordKey
.toVeilid(),
onContactSelected: onContactSelected,
onChatStarted: onChatStarted,
).paddingLTRB(8, 0, 8, 8)))),
if (enableRight)
if (_selectedContact == null)
const NoContactWidget().expanded()
else
ContactDetailsWidget(contact: _selectedContact!)
.paddingAll(8)
.expanded(),
]);
})));
}
Future<void> onContactSelected(proto.Contact? contact) async {
setState(() {
_selectedContact = contact;
});
}
Future<void> onChatStarted(proto.Contact contact) async {
final chatListCubit = context.read<ChatListCubit>();
await chatListCubit.getOrCreateChatSingleContact(contact: contact);
if (mounted) {
context
.read<ActiveChatCubit>()
.setActiveChat(contact.localConversationRecordKey.toVeilid());
Navigator.pop(context);
}
}
proto.Contact? _selectedContact;
}

View File

@ -0,0 +1,174 @@
import 'package:awesome_extensions/awesome_extensions.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:flutter_translate/flutter_translate.dart';
import '../../proto/proto.dart' as proto;
import '../../theme/theme.dart';
import 'availability_widget.dart';
class EditContactForm extends StatefulWidget {
const EditContactForm({
required this.formKey,
required this.contact,
this.onSubmit,
super.key,
});
@override
State createState() => _EditContactFormState();
final proto.Contact contact;
final Future<void> Function(GlobalKey<FormBuilderState>)? onSubmit;
final GlobalKey<FormBuilderState> formKey;
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties
..add(ObjectFlagProperty<
Future<void> Function(
GlobalKey<FormBuilderState> p1)?>.has('onSubmit', onSubmit))
..add(DiagnosticsProperty<proto.Contact>('contact', contact))
..add(
DiagnosticsProperty<GlobalKey<FormBuilderState>>('formKey', formKey));
}
static const String formFieldNickname = 'nickname';
static const String formFieldNotes = 'notes';
static const String formFieldShowAvailability = 'show_availability';
}
class _EditContactFormState extends State<EditContactForm> {
@override
void initState() {
super.initState();
}
Widget _availabilityWidget(
BuildContext context, proto.Availability availability) =>
AvailabilityWidget(availability: availability);
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scale = theme.extension<ScaleScheme>()!;
final scaleConfig = theme.extension<ScaleConfig>()!;
final textTheme = theme.textTheme;
late final Color border;
if (scaleConfig.useVisualIndicators && !scaleConfig.preferBorders) {
border = scale.primaryScale.elementBackground;
} else {
border = scale.primaryScale.border;
}
return FormBuilder(
key: widget.formKey,
child: Column(
children: [
AvatarWidget(
name: widget.contact.profile.name,
size: 128,
borderColor: border,
foregroundColor: scale.primaryScale.primaryText,
backgroundColor: scale.primaryScale.primary,
scaleConfig: scaleConfig,
textStyle: theme.textTheme.titleLarge!.copyWith(fontSize: 64),
).paddingLTRB(0, 0, 0, 16),
SelectableText(widget.contact.profile.name,
style: textTheme.headlineMedium)
.decoratorLabel(
context,
translate('contact_form.form_name'),
scale: scale.secondaryScale,
)
.paddingSymmetric(vertical: 8),
SelectableText(widget.contact.profile.pronouns,
style: textTheme.headlineSmall)
.decoratorLabel(
context,
translate('contact_form.form_pronouns'),
scale: scale.secondaryScale,
)
.paddingSymmetric(vertical: 8),
Row(children: [
_availabilityWidget(context, widget.contact.profile.availability),
SelectableText(widget.contact.profile.status,
style: textTheme.bodyMedium)
.paddingSymmetric(horizontal: 8)
])
.decoratorLabel(
context,
translate('contact_form.form_status'),
scale: scale.secondaryScale,
)
.paddingSymmetric(vertical: 8),
SelectableText(widget.contact.profile.about,
minLines: 1, maxLines: 8, style: textTheme.bodyMedium)
.decoratorLabel(
context,
translate('contact_form.form_about'),
scale: scale.secondaryScale,
)
.paddingSymmetric(vertical: 8),
SelectableText(
widget.contact.identityPublicKey.value.toVeilid().toString(),
style: textTheme.labelMedium!
.copyWith(fontFamily: 'Source Code Pro'))
.decoratorLabel(
context,
translate('contact_form.form_fingerprint'),
scale: scale.secondaryScale,
)
.paddingSymmetric(vertical: 8),
Divider(color: border).paddingLTRB(8, 0, 8, 8),
FormBuilderTextField(
autofocus: true,
name: EditContactForm.formFieldNickname,
initialValue: widget.contact.nickname,
decoration: InputDecoration(
labelText: translate('contact_form.form_nickname')),
maxLength: 64,
textInputAction: TextInputAction.next,
),
FormBuilderCheckbox(
name: EditContactForm.formFieldShowAvailability,
initialValue: widget.contact.showAvailability,
side: BorderSide(color: scale.primaryScale.border, width: 2),
title: Text(translate('contact_form.form_show_availability'),
style: textTheme.labelMedium),
),
FormBuilderTextField(
name: EditContactForm.formFieldNotes,
initialValue: widget.contact.notes,
minLines: 1,
maxLines: 8,
maxLength: 1024,
decoration: InputDecoration(
labelText: translate('contact_form.form_notes')),
textInputAction: TextInputAction.newline,
),
ElevatedButton(
onPressed: widget.onSubmit == null
? null
: () async {
if (widget.formKey.currentState?.saveAndValidate() ??
false) {
await widget.onSubmit!(widget.formKey);
}
},
child: Row(mainAxisSize: MainAxisSize.min, children: [
const Icon(Icons.check, size: 16).paddingLTRB(0, 0, 4, 0),
Text((widget.onSubmit == null)
? translate('contact_form.save')
: translate('contact_form.save'))
.paddingLTRB(0, 0, 4, 0)
]),
).paddingSymmetric(vertical: 4).alignAtCenterRight(),
],
),
);
}
}

View File

@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
import 'package:flutter_translate/flutter_translate.dart';
import '../../theme/models/scale_scheme.dart';
class NoContactWidget extends StatelessWidget {
const NoContactWidget({super.key});
@override
// ignore: prefer_expression_function_bodies
Widget build(
BuildContext context,
) {
final theme = Theme.of(context);
final scale = theme.extension<ScaleScheme>()!;
return DecoratedBox(
decoration: BoxDecoration(
color: scale.primaryScale.appBackground,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.person,
color: scale.primaryScale.subtleBorder,
size: 48,
),
Text(
textAlign: TextAlign.center,
translate('contacts_dialog.no_contact_selected'),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: scale.primaryScale.subtleBorder,
),
),
],
),
);
}
}

View File

@ -1,3 +1,8 @@
export 'availability_widget.dart';
export 'contact_details_widget.dart';
export 'contact_item_widget.dart'; export 'contact_item_widget.dart';
export 'contact_list_widget.dart'; export 'contacts_browser.dart';
export 'contacts_dialog.dart';
export 'edit_contact_form.dart';
export 'empty_contact_list_widget.dart'; export 'empty_contact_list_widget.dart';
export 'no_contact_widget.dart';

View File

@ -40,10 +40,10 @@ class _DrawerMenuState extends State<DrawerMenu> {
} }
void _doEditClick(TypedKey superIdentityRecordKey, void _doEditClick(TypedKey superIdentityRecordKey,
proto.Profile existingProfile, OwnedDHTRecordPointer accountRecord) { proto.Account existingAccount, OwnedDHTRecordPointer accountRecord) {
singleFuture(this, () async { singleFuture(this, () async {
await GoRouterHelper(context).push('/edit_account', await GoRouterHelper(context).push('/edit_account',
extra: [superIdentityRecordKey, existingProfile, accountRecord]); extra: [superIdentityRecordKey, existingAccount, accountRecord]);
}); });
} }
@ -58,6 +58,45 @@ class _DrawerMenuState extends State<DrawerMenu> {
borderRadius: BorderRadius.circular(borderRadius))), borderRadius: BorderRadius.circular(borderRadius))),
child: child); child: child);
Widget _makeAvatarWidget({
required String name,
required double size,
required Color borderColor,
required Color foregroundColor,
required Color backgroundColor,
required ScaleConfig scaleConfig,
required TextStyle textStyle,
ImageProvider<Object>? imageProvider,
}) {
final abbrev = name.split(' ').map((s) => s.isEmpty ? '' : s[0]).join();
late final String shortname;
if (abbrev.length >= 3) {
shortname = abbrev[0] + abbrev[1] + abbrev[abbrev.length - 1];
} else {
shortname = abbrev;
}
return Container(
height: size,
width: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: scaleConfig.preferBorders
? Border.all(
color: borderColor,
width: 2 * (size ~/ 32 + 1),
strokeAlign: BorderSide.strokeAlignOutside)
: null,
color: Colors.blue,
),
child: AvatarImage(
//size: 32,
backgroundImage: imageProvider,
backgroundColor: backgroundColor,
foregroundColor: foregroundColor,
child: Text(shortname, style: textStyle)));
}
Widget _makeAccountWidget( Widget _makeAccountWidget(
{required String name, {required String name,
required bool selected, required bool selected,
@ -67,13 +106,6 @@ class _DrawerMenuState extends State<DrawerMenu> {
required void Function()? callback, required void Function()? callback,
required void Function()? footerCallback}) { required void Function()? footerCallback}) {
final theme = Theme.of(context); final theme = Theme.of(context);
final abbrev = name.split(' ').map((s) => s.isEmpty ? '' : s[0]).join();
late final String shortname;
if (abbrev.length >= 3) {
shortname = abbrev[0] + abbrev[1] + abbrev[abbrev.length - 1];
} else {
shortname = abbrev;
}
late final Color background; late final Color background;
late final Color hoverBackground; late final Color hoverBackground;
@ -99,24 +131,15 @@ class _DrawerMenuState extends State<DrawerMenu> {
activeBorder = scale.primary; activeBorder = scale.primary;
} }
final avatar = Container( final avatar = AvatarWidget(
height: 34, name: name,
width: 34, size: 34,
decoration: BoxDecoration( borderColor: border,
shape: BoxShape.circle, foregroundColor: loggedIn ? scale.primaryText : scale.subtleText,
border: scaleConfig.preferBorders backgroundColor: loggedIn ? scale.primary : scale.elementBackground,
? Border.all( scaleConfig: scaleConfig,
color: border, textStyle: theme.textTheme.titleLarge!,
width: 2, );
strokeAlign: BorderSide.strokeAlignOutside)
: null,
color: Colors.blue,
),
child: AvatarImage(
//size: 32,
backgroundColor: loggedIn ? scale.primary : scale.elementBackground,
foregroundColor: loggedIn ? scale.primaryText : scale.subtleText,
child: Text(shortname, style: theme.textTheme.titleLarge)));
return AnimatedPadding( return AnimatedPadding(
padding: EdgeInsets.fromLTRB(selected ? 0 : 8, selected ? 0 : 2, padding: EdgeInsets.fromLTRB(selected ? 0 : 8, selected ? 0 : 2,
@ -190,7 +213,7 @@ class _DrawerMenuState extends State<DrawerMenu> {
footerCallback: () { footerCallback: () {
_doEditClick( _doEditClick(
superIdentityRecordKey, superIdentityRecordKey,
value.profile, value,
perAccountState.accountInfo.userLogin!.accountRecordInfo perAccountState.accountInfo.userLogin!.accountRecordInfo
.accountRecord); .accountRecord);
}), }),

View File

@ -6,9 +6,10 @@ import 'package:flutter_zoom_drawer/flutter_zoom_drawer.dart';
import '../../account_manager/account_manager.dart'; import '../../account_manager/account_manager.dart';
import '../../chat/chat.dart'; import '../../chat/chat.dart';
import '../../chat_list/chat_list.dart';
import '../../contacts/contacts.dart';
import '../../proto/proto.dart' as proto; import '../../proto/proto.dart' as proto;
import '../../theme/theme.dart'; import '../../theme/theme.dart';
import 'main_pager/main_pager.dart';
class HomeAccountReady extends StatefulWidget { class HomeAccountReady extends StatefulWidget {
const HomeAccountReady({super.key}); const HomeAccountReady({super.key});
@ -23,6 +24,75 @@ class _HomeAccountReadyState extends State<HomeAccountReady> {
super.initState(); super.initState();
} }
Widget buildMenuButton() => Builder(builder: (context) {
final theme = Theme.of(context);
final scale = theme.extension<ScaleScheme>()!;
final scaleConfig = theme.extension<ScaleConfig>()!;
return IconButton(
icon: const Icon(Icons.menu),
color: scaleConfig.preferBorders
? scale.primaryScale.border
: scale.primaryScale.borderText,
constraints: const BoxConstraints.expand(height: 48, width: 48),
style: ButtonStyle(
backgroundColor: WidgetStateProperty.all(
scaleConfig.preferBorders
? scale.primaryScale.hoverElementBackground
: scale.primaryScale.hoverBorder),
shape: WidgetStateProperty.all(
RoundedRectangleBorder(
side: !scaleConfig.useVisualIndicators
? BorderSide.none
: BorderSide(
strokeAlign: BorderSide.strokeAlignCenter,
color: scaleConfig.preferBorders
? scale.primaryScale.border
: scale.primaryScale.borderText,
width: 2),
borderRadius: BorderRadius.all(
Radius.circular(12 * scaleConfig.borderRadiusScale))),
)),
tooltip: translate('menu.accounts_menu_tooltip'),
onPressed: () async {
final ctrl = context.read<ZoomDrawerController>();
await ctrl.toggle?.call();
});
});
Widget buildContactsButton() => Builder(builder: (context) {
final theme = Theme.of(context);
final scale = theme.extension<ScaleScheme>()!;
final scaleConfig = theme.extension<ScaleConfig>()!;
return IconButton(
icon: const Icon(Icons.contacts),
color: scaleConfig.preferBorders
? scale.primaryScale.border
: scale.primaryScale.borderText,
constraints: const BoxConstraints.expand(height: 48, width: 48),
style: ButtonStyle(
backgroundColor: WidgetStateProperty.all(
scaleConfig.preferBorders
? scale.primaryScale.hoverElementBackground
: scale.primaryScale.hoverBorder),
shape: WidgetStateProperty.all(
RoundedRectangleBorder(
side: !scaleConfig.useVisualIndicators
? BorderSide.none
: BorderSide(
strokeAlign: BorderSide.strokeAlignCenter,
color: scaleConfig.preferBorders
? scale.primaryScale.border
: scale.primaryScale.borderText,
width: 2),
borderRadius: BorderRadius.all(
Radius.circular(12 * scaleConfig.borderRadiusScale))),
)),
tooltip: translate('menu.contacts_tooltip'),
onPressed: () async {
await ContactsDialog.show(context);
});
});
Widget buildUserPanel() => Builder(builder: (context) { Widget buildUserPanel() => Builder(builder: (context) {
final profile = context.select<AccountRecordCubit, proto.Profile>( final profile = context.select<AccountRecordCubit, proto.Profile>(
(c) => c.state.asData!.value.profile); (c) => c.state.asData!.value.profile);
@ -36,43 +106,14 @@ class _HomeAccountReadyState extends State<HomeAccountReady> {
: scale.primaryScale.subtleBorder, : scale.primaryScale.subtleBorder,
child: Column(children: <Widget>[ child: Column(children: <Widget>[
Row(children: [ Row(children: [
IconButton( buildMenuButton().paddingLTRB(0, 0, 8, 0),
icon: const Icon(Icons.menu),
color: scaleConfig.preferBorders
? scale.primaryScale.border
: scale.primaryScale.borderText,
constraints:
const BoxConstraints.expand(height: 48, width: 48),
style: ButtonStyle(
backgroundColor: WidgetStateProperty.all(
scaleConfig.preferBorders
? scale.primaryScale.hoverElementBackground
: scale.primaryScale.hoverBorder),
shape: WidgetStateProperty.all(
RoundedRectangleBorder(
side: !scaleConfig.useVisualIndicators
? BorderSide.none
: BorderSide(
strokeAlign: BorderSide.strokeAlignCenter,
color: scaleConfig.preferBorders
? scale.primaryScale.border
: scale.primaryScale.borderText,
width: 2),
borderRadius: BorderRadius.all(Radius.circular(
12 * scaleConfig.borderRadiusScale))),
)),
tooltip: translate('menu.settings_tooltip'),
onPressed: () async {
final ctrl = context.read<ZoomDrawerController>();
await ctrl.toggle?.call();
//await GoRouterHelper(context).push('/settings');
}).paddingLTRB(0, 0, 8, 0),
ProfileWidget( ProfileWidget(
profile: profile, profile: profile,
showPronouns: false, showPronouns: false,
).expanded(), ).expanded(),
buildContactsButton().paddingLTRB(8, 0, 0, 0),
]).paddingAll(8), ]).paddingAll(8),
MainPager(key: _mainPagerKey).expanded() const ChatListWidget().expanded()
])); ]));
}); });
@ -156,7 +197,4 @@ class _HomeAccountReadyState extends State<HomeAccountReady> {
]); ]);
}); });
} }
////////////////////////////////////////////////////////////////////////////
final _mainPagerKey = GlobalKey(debugLabel: '_mainPagerKey');
} }

View File

@ -132,7 +132,14 @@ class HomeScreenState extends State<HomeScreen>
// Re-export all ready blocs to the account display subtree // Re-export all ready blocs to the account display subtree
return perAccountCollectionState.provide( return perAccountCollectionState.provide(
child: const HomeAccountReady()); child: Navigator(
onPopPage: (route, result) {
if (!route.didPop(result)) {
return false;
}
return true;
},
pages: const [MaterialPage(child: HomeAccountReady())]));
} }
} }

View File

@ -1,68 +0,0 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
class BottomSheetActionButton extends StatefulWidget {
const BottomSheetActionButton(
{required this.bottomSheetBuilder,
required this.builder,
this.foregroundColor,
this.backgroundColor,
this.shape,
super.key});
final Color? foregroundColor;
final Color? backgroundColor;
final ShapeBorder? shape;
final Widget Function(BuildContext) builder;
final Widget Function(BuildContext) bottomSheetBuilder;
@override
BottomSheetActionButtonState createState() => BottomSheetActionButtonState();
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties
..add(ObjectFlagProperty<Widget Function(BuildContext p1)>.has(
'bottomSheetBuilder', bottomSheetBuilder))
..add(ColorProperty('foregroundColor', foregroundColor))
..add(ColorProperty('backgroundColor', backgroundColor))
..add(DiagnosticsProperty<ShapeBorder?>('shape', shape))
..add(ObjectFlagProperty<Widget? Function(BuildContext p1)>.has(
'builder', builder));
}
}
class BottomSheetActionButtonState extends State<BottomSheetActionButton> {
bool _showFab = true;
@override
void initState() {
super.initState();
}
@override
// ignore: prefer_expression_function_bodies
Widget build(BuildContext context) {
//
return _showFab
? FloatingActionButton(
elevation: 0,
heroTag: this,
hoverElevation: 0,
shape: widget.shape,
foregroundColor: widget.foregroundColor,
backgroundColor: widget.backgroundColor,
child: widget.builder(context),
onPressed: () async {
await showModalBottomSheet<void>(
context: context, builder: widget.bottomSheetBuilder);
},
)
: Container();
}
void showFloatingActionButton(bool value) {
setState(() {
_showFab = value;
});
}
}

View File

@ -1,28 +0,0 @@
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> {
@override
void initState() {
super.initState();
}
@override
void dispose() {
super.dispose();
}
@override
// ignore: prefer_expression_function_bodies
Widget build(BuildContext context) {
return const ChatListWidget();
}
}

View File

@ -1,58 +0,0 @@
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 '../../../contact_invitation/contact_invitation.dart';
import '../../../contacts/contacts.dart';
class ContactsPage extends StatefulWidget {
const ContactsPage({
super.key,
});
@override
ContactsPageState createState() => ContactsPageState();
}
class ContactsPageState extends State<ContactsPage> {
@override
void initState() {
super.initState();
}
@override
void dispose() {
super.dispose();
}
@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 scaleConfig = theme.extension<ScaleConfig>()!;
final cilState = context.watch<ContactInvitationListCubit>().state;
final cilBusy = cilState.busy;
final contactInvitationRecordList =
cilState.state.asData?.value.map((x) => x.value).toIList() ??
const IListConst([]);
final ciState = context.watch<ContactListCubit>().state;
final ciBusy = ciState.busy;
final contactList =
ciState.state.asData?.value.map((x) => x.value).toIList();
return CustomScrollView(slivers: [
if (contactInvitationRecordList.isNotEmpty)
SliverPadding(
padding: const EdgeInsets.only(bottom: 8),
sliver: ContactInvitationListWidget(
contactInvitationRecordList: contactInvitationRecordList,
disabled: cilBusy)),
ContactListWidget(contactList: contactList, disabled: ciBusy)
]).paddingLTRB(8, 0, 8, 8);
}
}

View File

@ -1,242 +0,0 @@
import 'dart:async';
import 'package:animated_bottom_navigation_bar/'
'animated_bottom_navigation_bar.dart';
import 'package:awesome_extensions/awesome_extensions.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_translate/flutter_translate.dart';
import 'package:preload_page_view/preload_page_view.dart';
import 'package:provider/provider.dart';
import '../../../chat/chat.dart';
import '../../../contact_invitation/contact_invitation.dart';
import '../../../theme/theme.dart';
import 'bottom_sheet_action_button.dart';
import 'chats_page.dart';
import 'contacts_page.dart';
class MainPager extends StatefulWidget {
const MainPager({super.key});
@override
MainPagerState createState() => MainPagerState();
static MainPagerState? of(BuildContext context) =>
context.findAncestorStateOfType<MainPagerState>();
}
class MainPagerState extends State<MainPager> with TickerProviderStateMixin {
//////////////////////////////////////////////////////////////////
@override
void initState() {
super.initState();
}
@override
void dispose() {
pageController.dispose();
super.dispose();
}
Future<void> scanContactInvitationDialog(BuildContext context) async {
await showDialog<void>(
context: context,
// ignore: prefer_expression_function_bodies
builder: (context) {
return AlertDialog(
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(20)),
),
contentPadding: const EdgeInsets.only(
top: 10,
),
title: const Text(
'Scan Contact Invite',
style: TextStyle(fontSize: 24),
),
content: ScanInvitationDialog(
locator: context.read,
));
});
}
Widget _buildBottomBarItem(int index, bool isActive) {
final theme = Theme.of(context);
final scale = theme.extension<ScaleScheme>()!;
final scaleConfig = theme.extension<ScaleConfig>()!;
final color = scaleConfig.useVisualIndicators
? (scaleConfig.preferBorders
? scale.primaryScale.border
: scale.primaryScale.borderText)
: (isActive
? (scaleConfig.preferBorders
? scale.primaryScale.border
: scale.primaryScale.borderText)
: (scaleConfig.preferBorders
? scale.primaryScale.subtleBorder
: scale.primaryScale.borderText.withAlpha(0x80)));
final item = Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
_selectedIconList[index],
size: 24,
color: color,
),
const SizedBox(height: 4),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Text(
_bottomLabelList[index],
style: theme.textTheme.labelMedium!.copyWith(
fontWeight: isActive ? FontWeight.bold : FontWeight.normal,
color: color),
),
)
],
);
if (scaleConfig.useVisualIndicators && isActive) {
return DecoratedBox(
decoration: ShapeDecoration(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
14 * scaleConfig.borderRadiusScale),
side: BorderSide(
width: 2,
color: scaleConfig.preferBorders
? scale.primaryScale.border
: scale.primaryScale.borderText))),
child: item)
.paddingLTRB(8, 0, 8, 6);
}
return item;
}
Widget _bottomSheetBuilder(BuildContext sheetContext, BuildContext context) {
if (currentPage == 0) {
// New contact invitation
return newContactBottomSheetBuilder(sheetContext, context);
} else if (currentPage == 1) {
// New chat
return newChatBottomSheetBuilder(sheetContext, context);
} else {
// Unknown error
return debugPage('unknown page');
}
}
@override
// ignore: prefer_expression_function_bodies
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scale = theme.extension<ScaleScheme>()!;
final scaleConfig = theme.extension<ScaleConfig>()!;
return Scaffold(
//extendBody: true,
backgroundColor: Colors.transparent,
body: PreloadPageView(
key: _pageViewKey,
controller: pageController,
preloadPagesCount: 2,
onPageChanged: (index) {
setState(() {
currentPage = index;
});
},
children: const [
ContactsPage(),
ChatsPage(),
]),
// appBar: AppBar(
// toolbarHeight: 24,
// title: Text(
// 'C',
// style: Theme.of(context).textTheme.headlineSmall,
// ),
// ),
bottomNavigationBar: AnimatedBottomNavigationBar.builder(
itemCount: 2,
height: 64,
tabBuilder: _buildBottomBarItem,
activeIndex: currentPage,
gapLocation: GapLocation.end,
gapWidth: 90,
notchSmoothness: NotchSmoothness.defaultEdge,
notchMargin: 4,
backgroundColor: scaleConfig.preferBorders
? scale.primaryScale.hoverElementBackground
: scale.primaryScale.hoverBorder,
elevation: 0,
onTap: (index) async {
await pageController.animateToPage(index,
duration: 250.ms, curve: Curves.easeInOut);
},
),
floatingActionButton: BottomSheetActionButton(
shape: CircleBorder(
side: !scaleConfig.useVisualIndicators
? BorderSide.none
: BorderSide(
strokeAlign: BorderSide.strokeAlignCenter,
color: scaleConfig.preferBorders
? scale.secondaryScale.border
: scale.secondaryScale.borderText,
width: 2),
),
foregroundColor: scaleConfig.preferBorders
? scale.secondaryScale.border
: scale.secondaryScale.borderText,
backgroundColor: scaleConfig.preferBorders
? scale.secondaryScale.hoverElementBackground
: scale.secondaryScale.hoverBorder,
builder: (context) => Icon(
_fabIconList[currentPage],
color: scaleConfig.preferBorders
? scale.secondaryScale.border
: scale.secondaryScale.borderText,
),
bottomSheetBuilder: (sheetContext) =>
_bottomSheetBuilder(sheetContext, context)),
floatingActionButtonLocation: FloatingActionButtonLocation.endDocked,
);
}
//////////////////////////////////////////////////////////////////
final _selectedIconList = <IconData>[Icons.person, Icons.chat];
// final _unselectedIconList = <IconData>[
// Icons.chat_outlined,
// Icons.person_outlined
// ];
final _fabIconList = <IconData>[
Icons.person_add_sharp,
Icons.chat,
];
final _bottomLabelList = <String>[
translate('pager.contacts'),
translate('pager.chats'),
];
final _pageViewKey = GlobalKey(debugLabel: '_pageViewKey');
// key-accessible controller
int currentPage = 0;
final pageController = PreloadPageController();
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties
..add(IntProperty('currentPage', currentPage))
..add(DiagnosticsProperty<PreloadPageController>(
'pageController', pageController));
}
}

View File

@ -1,4 +1,3 @@
export 'default_app_bar.dart'; export 'default_app_bar.dart';
export 'home/home.dart'; export 'home/home.dart';
export 'home/main_pager/main_pager.dart';
export 'splash.dart'; export 'splash.dart';

View File

@ -52,7 +52,11 @@ Widget buildSettingsPageNotificationPreferences(
out.add(DropdownMenuItem( out.add(DropdownMenuItem(
value: x.$1, value: x.$1,
enabled: x.$2, enabled: x.$2,
child: Text(x.$3, style: textTheme.labelSmall))); child: Text(
x.$3,
style: textTheme.labelSmall,
textAlign: TextAlign.center,
)));
} }
return out; return out;
} }
@ -71,7 +75,11 @@ Widget buildSettingsPageNotificationPreferences(
out.add(DropdownMenuItem( out.add(DropdownMenuItem(
value: x.$1, value: x.$1,
enabled: x.$2, enabled: x.$2,
child: Text(x.$3, style: textTheme.labelSmall))); child: Text(
x.$3,
style: textTheme.labelSmall,
textAlign: TextAlign.center,
)));
} }
return out; return out;
} }
@ -100,17 +108,23 @@ Widget buildSettingsPageNotificationPreferences(
out.add(DropdownMenuItem( out.add(DropdownMenuItem(
value: x.$1, value: x.$1,
enabled: x.$2, enabled: x.$2,
child: Text(x.$3, style: textTheme.labelSmall))); child: Text(
x.$3,
style: textTheme.labelSmall,
textAlign: TextAlign.center,
)));
} }
return out; return out;
} }
return DecoratedBox( return InputDecorator(
decoration: ShapeDecoration( decoration: InputDecoration(
shape: RoundedRectangleBorder( labelText: translate('settings_page.notifications'),
side: BorderSide(width: 2, color: scale.primaryScale.border), border: OutlineInputBorder(
borderRadius: borderRadius: BorderRadius.circular(8 * scaleConfig.borderRadiusScale),
BorderRadius.circular(8 * scaleConfig.borderRadiusScale))), borderSide: BorderSide(width: 2, color: scale.primaryScale.border),
),
),
child: Column(mainAxisSize: MainAxisSize.min, children: [ child: Column(mainAxisSize: MainAxisSize.min, children: [
// Display Beta Warning // Display Beta Warning
FormBuilderCheckbox( FormBuilderCheckbox(
@ -175,12 +189,35 @@ Widget buildSettingsPageNotificationPreferences(
await updatePreferences(newNotificationsPreference); await updatePreferences(newNotificationsPreference);
}, },
items: messageNotificationContentItems(), items: messageNotificationContentItems(),
).paddingAll(8), ).paddingLTRB(0, 4, 0, 4),
// Notifications // Notifications
Table( Table(
defaultVerticalAlignment: TableCellVerticalAlignment.middle, defaultVerticalAlignment: TableCellVerticalAlignment.middle,
children: [ children: [
TableRow(children: [
Text(translate('settings_page.event'),
textAlign: TextAlign.center,
style: textTheme.titleMedium!.copyWith(
color: scale.primaryScale.border,
decorationColor: scale.primaryScale.border,
decoration: TextDecoration.underline))
.paddingAll(8),
Text(translate('settings_page.delivery'),
textAlign: TextAlign.center,
style: textTheme.titleMedium!.copyWith(
color: scale.primaryScale.border,
decorationColor: scale.primaryScale.border,
decoration: TextDecoration.underline))
.paddingAll(8),
Text(translate('settings_page.sound'),
textAlign: TextAlign.center,
style: textTheme.titleMedium!.copyWith(
color: scale.primaryScale.border,
decorationColor: scale.primaryScale.border,
decoration: TextDecoration.underline))
.paddingAll(8),
]),
TableRow(children: [ TableRow(children: [
// Invitation accepted // Invitation accepted
Text( Text(
@ -216,7 +253,7 @@ Widget buildSettingsPageNotificationPreferences(
await updatePreferences(newNotificationsPreference); await updatePreferences(newNotificationsPreference);
}, },
items: soundEffectItems(), items: soundEffectItems(),
).paddingAll(4) ).paddingLTRB(4, 4, 0, 4)
]), ]),
// Message received // Message received
TableRow(children: [ TableRow(children: [
@ -253,7 +290,7 @@ Widget buildSettingsPageNotificationPreferences(
await updatePreferences(newNotificationsPreference); await updatePreferences(newNotificationsPreference);
}, },
items: soundEffectItems(), items: soundEffectItems(),
).paddingAll(4) ).paddingLTRB(4, 4, 0, 4)
]), ]),
// Message sent // Message sent
@ -277,9 +314,9 @@ Widget buildSettingsPageNotificationPreferences(
await updatePreferences(newNotificationsPreference); await updatePreferences(newNotificationsPreference);
}, },
items: soundEffectItems(), items: soundEffectItems(),
).paddingAll(4) ).paddingLTRB(4, 4, 0, 4)
]), ]),
]).paddingAll(8) ])
]).paddingAll(8), ]).paddingAll(8),
); );
} }

View File

@ -31,6 +31,7 @@ extension MessageExt on proto.Message {
} }
extension ContactExt on proto.Contact { extension ContactExt on proto.Contact {
String get nameOrNickname => nickname.isNotEmpty ? nickname : profile.name;
String get displayName => String get displayName =>
nickname.isNotEmpty ? '$nickname (${profile.name})' : profile.name; nickname.isNotEmpty ? '$nickname (${profile.name})' : profile.name;
} }

View File

@ -1647,11 +1647,15 @@ class Account extends $pb.GeneratedMessage {
static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'Account', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'Account', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create)
..aOM<Profile>(1, _omitFieldNames ? '' : 'profile', subBuilder: Profile.create) ..aOM<Profile>(1, _omitFieldNames ? '' : 'profile', subBuilder: Profile.create)
..aOB(2, _omitFieldNames ? '' : 'invisible') ..aOB(2, _omitFieldNames ? '' : 'invisible')
..a<$core.int>(3, _omitFieldNames ? '' : 'autoAwayTimeoutSec', $pb.PbFieldType.OU3) ..a<$core.int>(3, _omitFieldNames ? '' : 'autoAwayTimeoutMin', $pb.PbFieldType.OU3)
..aOM<$1.OwnedDHTRecordPointer>(4, _omitFieldNames ? '' : 'contactList', subBuilder: $1.OwnedDHTRecordPointer.create) ..aOM<$1.OwnedDHTRecordPointer>(4, _omitFieldNames ? '' : 'contactList', subBuilder: $1.OwnedDHTRecordPointer.create)
..aOM<$1.OwnedDHTRecordPointer>(5, _omitFieldNames ? '' : 'contactInvitationRecords', subBuilder: $1.OwnedDHTRecordPointer.create) ..aOM<$1.OwnedDHTRecordPointer>(5, _omitFieldNames ? '' : 'contactInvitationRecords', subBuilder: $1.OwnedDHTRecordPointer.create)
..aOM<$1.OwnedDHTRecordPointer>(6, _omitFieldNames ? '' : 'chatList', subBuilder: $1.OwnedDHTRecordPointer.create) ..aOM<$1.OwnedDHTRecordPointer>(6, _omitFieldNames ? '' : 'chatList', subBuilder: $1.OwnedDHTRecordPointer.create)
..aOM<$1.OwnedDHTRecordPointer>(7, _omitFieldNames ? '' : 'groupChatList', subBuilder: $1.OwnedDHTRecordPointer.create) ..aOM<$1.OwnedDHTRecordPointer>(7, _omitFieldNames ? '' : 'groupChatList', subBuilder: $1.OwnedDHTRecordPointer.create)
..aOS(8, _omitFieldNames ? '' : 'freeMessage')
..aOS(9, _omitFieldNames ? '' : 'busyMessage')
..aOS(10, _omitFieldNames ? '' : 'awayMessage')
..aOB(11, _omitFieldNames ? '' : 'autodetectAway')
..hasRequiredFields = false ..hasRequiredFields = false
; ;
@ -1697,13 +1701,13 @@ class Account extends $pb.GeneratedMessage {
void clearInvisible() => clearField(2); void clearInvisible() => clearField(2);
@$pb.TagNumber(3) @$pb.TagNumber(3)
$core.int get autoAwayTimeoutSec => $_getIZ(2); $core.int get autoAwayTimeoutMin => $_getIZ(2);
@$pb.TagNumber(3) @$pb.TagNumber(3)
set autoAwayTimeoutSec($core.int v) { $_setUnsignedInt32(2, v); } set autoAwayTimeoutMin($core.int v) { $_setUnsignedInt32(2, v); }
@$pb.TagNumber(3) @$pb.TagNumber(3)
$core.bool hasAutoAwayTimeoutSec() => $_has(2); $core.bool hasAutoAwayTimeoutMin() => $_has(2);
@$pb.TagNumber(3) @$pb.TagNumber(3)
void clearAutoAwayTimeoutSec() => clearField(3); void clearAutoAwayTimeoutMin() => clearField(3);
@$pb.TagNumber(4) @$pb.TagNumber(4)
$1.OwnedDHTRecordPointer get contactList => $_getN(3); $1.OwnedDHTRecordPointer get contactList => $_getN(3);
@ -1748,6 +1752,42 @@ class Account extends $pb.GeneratedMessage {
void clearGroupChatList() => clearField(7); void clearGroupChatList() => clearField(7);
@$pb.TagNumber(7) @$pb.TagNumber(7)
$1.OwnedDHTRecordPointer ensureGroupChatList() => $_ensure(6); $1.OwnedDHTRecordPointer ensureGroupChatList() => $_ensure(6);
@$pb.TagNumber(8)
$core.String get freeMessage => $_getSZ(7);
@$pb.TagNumber(8)
set freeMessage($core.String v) { $_setString(7, v); }
@$pb.TagNumber(8)
$core.bool hasFreeMessage() => $_has(7);
@$pb.TagNumber(8)
void clearFreeMessage() => clearField(8);
@$pb.TagNumber(9)
$core.String get busyMessage => $_getSZ(8);
@$pb.TagNumber(9)
set busyMessage($core.String v) { $_setString(8, v); }
@$pb.TagNumber(9)
$core.bool hasBusyMessage() => $_has(8);
@$pb.TagNumber(9)
void clearBusyMessage() => clearField(9);
@$pb.TagNumber(10)
$core.String get awayMessage => $_getSZ(9);
@$pb.TagNumber(10)
set awayMessage($core.String v) { $_setString(9, v); }
@$pb.TagNumber(10)
$core.bool hasAwayMessage() => $_has(9);
@$pb.TagNumber(10)
void clearAwayMessage() => clearField(10);
@$pb.TagNumber(11)
$core.bool get autodetectAway => $_getBF(10);
@$pb.TagNumber(11)
set autodetectAway($core.bool v) { $_setBool(10, v); }
@$pb.TagNumber(11)
$core.bool hasAutodetectAway() => $_has(10);
@$pb.TagNumber(11)
void clearAutodetectAway() => clearField(11);
} }
class Contact extends $pb.GeneratedMessage { class Contact extends $pb.GeneratedMessage {

View File

@ -467,24 +467,31 @@ const Account$json = {
'2': [ '2': [
{'1': 'profile', '3': 1, '4': 1, '5': 11, '6': '.veilidchat.Profile', '10': 'profile'}, {'1': 'profile', '3': 1, '4': 1, '5': 11, '6': '.veilidchat.Profile', '10': 'profile'},
{'1': 'invisible', '3': 2, '4': 1, '5': 8, '10': 'invisible'}, {'1': 'invisible', '3': 2, '4': 1, '5': 8, '10': 'invisible'},
{'1': 'auto_away_timeout_sec', '3': 3, '4': 1, '5': 13, '10': 'autoAwayTimeoutSec'}, {'1': 'auto_away_timeout_min', '3': 3, '4': 1, '5': 13, '10': 'autoAwayTimeoutMin'},
{'1': 'contact_list', '3': 4, '4': 1, '5': 11, '6': '.dht.OwnedDHTRecordPointer', '10': 'contactList'}, {'1': 'contact_list', '3': 4, '4': 1, '5': 11, '6': '.dht.OwnedDHTRecordPointer', '10': 'contactList'},
{'1': 'contact_invitation_records', '3': 5, '4': 1, '5': 11, '6': '.dht.OwnedDHTRecordPointer', '10': 'contactInvitationRecords'}, {'1': 'contact_invitation_records', '3': 5, '4': 1, '5': 11, '6': '.dht.OwnedDHTRecordPointer', '10': 'contactInvitationRecords'},
{'1': 'chat_list', '3': 6, '4': 1, '5': 11, '6': '.dht.OwnedDHTRecordPointer', '10': 'chatList'}, {'1': 'chat_list', '3': 6, '4': 1, '5': 11, '6': '.dht.OwnedDHTRecordPointer', '10': 'chatList'},
{'1': 'group_chat_list', '3': 7, '4': 1, '5': 11, '6': '.dht.OwnedDHTRecordPointer', '10': 'groupChatList'}, {'1': 'group_chat_list', '3': 7, '4': 1, '5': 11, '6': '.dht.OwnedDHTRecordPointer', '10': 'groupChatList'},
{'1': 'free_message', '3': 8, '4': 1, '5': 9, '10': 'freeMessage'},
{'1': 'busy_message', '3': 9, '4': 1, '5': 9, '10': 'busyMessage'},
{'1': 'away_message', '3': 10, '4': 1, '5': 9, '10': 'awayMessage'},
{'1': 'autodetect_away', '3': 11, '4': 1, '5': 8, '10': 'autodetectAway'},
], ],
}; };
/// Descriptor for `Account`. Decode as a `google.protobuf.DescriptorProto`. /// Descriptor for `Account`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List accountDescriptor = $convert.base64Decode( final $typed_data.Uint8List accountDescriptor = $convert.base64Decode(
'CgdBY2NvdW50Ei0KB3Byb2ZpbGUYASABKAsyEy52ZWlsaWRjaGF0LlByb2ZpbGVSB3Byb2ZpbG' 'CgdBY2NvdW50Ei0KB3Byb2ZpbGUYASABKAsyEy52ZWlsaWRjaGF0LlByb2ZpbGVSB3Byb2ZpbG'
'USHAoJaW52aXNpYmxlGAIgASgIUglpbnZpc2libGUSMQoVYXV0b19hd2F5X3RpbWVvdXRfc2Vj' 'USHAoJaW52aXNpYmxlGAIgASgIUglpbnZpc2libGUSMQoVYXV0b19hd2F5X3RpbWVvdXRfbWlu'
'GAMgASgNUhJhdXRvQXdheVRpbWVvdXRTZWMSPQoMY29udGFjdF9saXN0GAQgASgLMhouZGh0Lk' 'GAMgASgNUhJhdXRvQXdheVRpbWVvdXRNaW4SPQoMY29udGFjdF9saXN0GAQgASgLMhouZGh0Lk'
'93bmVkREhUUmVjb3JkUG9pbnRlclILY29udGFjdExpc3QSWAoaY29udGFjdF9pbnZpdGF0aW9u' '93bmVkREhUUmVjb3JkUG9pbnRlclILY29udGFjdExpc3QSWAoaY29udGFjdF9pbnZpdGF0aW9u'
'X3JlY29yZHMYBSABKAsyGi5kaHQuT3duZWRESFRSZWNvcmRQb2ludGVyUhhjb250YWN0SW52aX' 'X3JlY29yZHMYBSABKAsyGi5kaHQuT3duZWRESFRSZWNvcmRQb2ludGVyUhhjb250YWN0SW52aX'
'RhdGlvblJlY29yZHMSNwoJY2hhdF9saXN0GAYgASgLMhouZGh0Lk93bmVkREhUUmVjb3JkUG9p' 'RhdGlvblJlY29yZHMSNwoJY2hhdF9saXN0GAYgASgLMhouZGh0Lk93bmVkREhUUmVjb3JkUG9p'
'bnRlclIIY2hhdExpc3QSQgoPZ3JvdXBfY2hhdF9saXN0GAcgASgLMhouZGh0Lk93bmVkREhUUm' 'bnRlclIIY2hhdExpc3QSQgoPZ3JvdXBfY2hhdF9saXN0GAcgASgLMhouZGh0Lk93bmVkREhUUm'
'Vjb3JkUG9pbnRlclINZ3JvdXBDaGF0TGlzdA=='); 'Vjb3JkUG9pbnRlclINZ3JvdXBDaGF0TGlzdBIhCgxmcmVlX21lc3NhZ2UYCCABKAlSC2ZyZWVN'
'ZXNzYWdlEiEKDGJ1c3lfbWVzc2FnZRgJIAEoCVILYnVzeU1lc3NhZ2USIQoMYXdheV9tZXNzYW'
'dlGAogASgJUgthd2F5TWVzc2FnZRInCg9hdXRvZGV0ZWN0X2F3YXkYCyABKAhSDmF1dG9kZXRl'
'Y3RBd2F5');
@$core.Deprecated('Use contactDescriptor instead') @$core.Deprecated('Use contactDescriptor instead')
const Contact$json = { const Contact$json = {

View File

@ -319,13 +319,13 @@ message Chat {
// Pronouns - Pronouns of user // Pronouns - Pronouns of user
// Icon - Little picture to represent user in contact list // Icon - Little picture to represent user in contact list
message Profile { message Profile {
// Friendy name // Friendy name (max length 64)
string name = 1; string name = 1;
// Pronouns of user // Pronouns of user (max length 64)
string pronouns = 2; string pronouns = 2;
// Description of the user // Description of the user (max length 1024)
string about = 3; string about = 3;
// Status/away message // Status/away message (max length 128)
string status = 4; string status = 4;
// Availability // Availability
Availability availability = 5; Availability availability = 5;
@ -345,8 +345,8 @@ message Account {
Profile profile = 1; Profile profile = 1;
// Invisibility makes you always look 'Offline' // Invisibility makes you always look 'Offline'
bool invisible = 2; bool invisible = 2;
// Auto-away sets 'away' mode after an inactivity time // Auto-away sets 'away' mode after an inactivity time (only if autodetect_away is set)
uint32 auto_away_timeout_sec = 3; uint32 auto_away_timeout_min = 3;
// The contacts DHTList for this account // The contacts DHTList for this account
// DHT Private // DHT Private
dht.OwnedDHTRecordPointer contact_list = 4; dht.OwnedDHTRecordPointer contact_list = 4;
@ -359,6 +359,15 @@ message Account {
// The GroupChats DHTList for this account // The GroupChats DHTList for this account
// DHT Private // DHT Private
dht.OwnedDHTRecordPointer group_chat_list = 7; dht.OwnedDHTRecordPointer group_chat_list = 7;
// Free message (max length 128)
string free_message = 8;
// Busy message (max length 128)
string busy_message = 9;
// Away message (max length 128)
string away_message = 10;
// Auto-detect away
bool autodetect_away = 11;
} }
// A record of a contact that has accepted a contact invitation // A record of a contact that has accepted a contact invitation

View File

@ -72,7 +72,7 @@ class RouterCubit extends Cubit<RouterState> {
final extra = state.extra! as List<Object?>; final extra = state.extra! as List<Object?>;
return EditAccountPage( return EditAccountPage(
superIdentityRecordKey: extra[0]! as TypedKey, superIdentityRecordKey: extra[0]! as TypedKey,
existingProfile: extra[1]! as proto.Profile, existingAccount: extra[1]! as proto.Account,
accountRecord: extra[2]! as OwnedDHTRecordPointer, accountRecord: extra[2]! as OwnedDHTRecordPointer,
); );
}, },

View File

@ -47,7 +47,9 @@ class SettingsPageState extends State<SettingsPage> {
child: ListView( child: ListView(
children: [ children: [
buildSettingsPageColorPreferences( buildSettingsPageColorPreferences(
context: context, onChanged: () => setState(() {})), context: context,
onChanged: () => setState(() {}))
.paddingLTRB(0, 8, 0, 0),
buildSettingsPageBrightnessPreferences( buildSettingsPageBrightnessPreferences(
context: context, onChanged: () => setState(() {})), context: context, onChanged: () => setState(() {})),
buildSettingsPageNotificationPreferences( buildSettingsPageNotificationPreferences(

View File

@ -30,7 +30,9 @@ class SliderTile extends StatelessWidget {
this.endActions = const [], this.endActions = const [],
this.startActions = const [], this.startActions = const [],
this.onTap, this.onTap,
this.icon, this.onDoubleTap,
this.leading,
this.trailing,
super.key}); super.key});
final bool disabled; final bool disabled;
@ -39,7 +41,9 @@ class SliderTile extends StatelessWidget {
final List<SliderTileAction> endActions; final List<SliderTileAction> endActions;
final List<SliderTileAction> startActions; final List<SliderTileAction> startActions;
final GestureTapCallback? onTap; final GestureTapCallback? onTap;
final IconData? icon; final GestureTapCallback? onDoubleTap;
final Widget? leading;
final Widget? trailing;
final String title; final String title;
final String subtitle; final String subtitle;
@ -53,9 +57,12 @@ class SliderTile extends StatelessWidget {
..add(IterableProperty<SliderTileAction>('endActions', endActions)) ..add(IterableProperty<SliderTileAction>('endActions', endActions))
..add(IterableProperty<SliderTileAction>('startActions', startActions)) ..add(IterableProperty<SliderTileAction>('startActions', startActions))
..add(ObjectFlagProperty<GestureTapCallback?>.has('onTap', onTap)) ..add(ObjectFlagProperty<GestureTapCallback?>.has('onTap', onTap))
..add(DiagnosticsProperty<IconData?>('icon', icon)) ..add(DiagnosticsProperty<Widget?>('leading', leading))
..add(StringProperty('title', title)) ..add(StringProperty('title', title))
..add(StringProperty('subtitle', subtitle)); ..add(StringProperty('subtitle', subtitle))
..add(ObjectFlagProperty<GestureTapCallback?>.has(
'onDoubleTap', onDoubleTap))
..add(DiagnosticsProperty<Widget?>('trailing', trailing));
} }
@override @override
@ -138,18 +145,21 @@ class SliderTile extends StatelessWidget {
padding: scaleConfig.useVisualIndicators padding: scaleConfig.useVisualIndicators
? EdgeInsets.zero ? EdgeInsets.zero
: const EdgeInsets.fromLTRB(0, 2, 0, 2), : const EdgeInsets.fromLTRB(0, 2, 0, 2),
child: ListTile( child: GestureDetector(
onTap: onTap, onDoubleTap: onDoubleTap,
dense: true, child: ListTile(
visualDensity: const VisualDensity(vertical: -4), onTap: onTap,
title: Text( dense: true,
title, visualDensity: const VisualDensity(vertical: -4),
overflow: TextOverflow.fade, title: Text(
softWrap: false, title,
), overflow: TextOverflow.fade,
subtitle: subtitle.isNotEmpty ? Text(subtitle) : null, softWrap: false,
iconColor: textColor, ),
textColor: textColor, subtitle: subtitle.isNotEmpty ? Text(subtitle) : null,
leading: icon == null ? null : Icon(icon))))); iconColor: textColor,
textColor: textColor,
leading: FittedBox(child: leading),
trailing: FittedBox(child: trailing))))));
} }
} }

View File

@ -0,0 +1,77 @@
import 'package:awesome_extensions/awesome_extensions.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import '../theme.dart';
class AvatarWidget extends StatelessWidget {
AvatarWidget({
required String name,
required double size,
required Color borderColor,
required Color foregroundColor,
required Color backgroundColor,
required ScaleConfig scaleConfig,
required TextStyle textStyle,
super.key,
ImageProvider<Object>? imageProvider,
}) : _name = name,
_size = size,
_borderColor = borderColor,
_foregroundColor = foregroundColor,
_backgroundColor = backgroundColor,
_scaleConfig = scaleConfig,
_textStyle = textStyle,
_imageProvider = imageProvider;
@override
Widget build(BuildContext context) {
final abbrev = _name.split(' ').map((s) => s.isEmpty ? '' : s[0]).join();
late final String shortname;
if (abbrev.length >= 3) {
shortname = abbrev[0] + abbrev[1] + abbrev[abbrev.length - 1];
} else {
shortname = abbrev;
}
return Container(
height: _size,
width: _size,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: _scaleConfig.preferBorders
? Border.all(
color: _borderColor,
width: 1 * (_size ~/ 32 + 1),
strokeAlign: BorderSide.strokeAlignOutside)
: null,
color: _borderColor,
),
child: AvatarImage(
//size: 32,
backgroundImage: _imageProvider,
backgroundColor:
_scaleConfig.useVisualIndicators && !_scaleConfig.preferBorders
? _foregroundColor
: _backgroundColor,
child: Text(
shortname,
style: _textStyle.copyWith(
color: _scaleConfig.useVisualIndicators &&
!_scaleConfig.preferBorders
? _backgroundColor
: _foregroundColor,
),
)));
}
////////////////////////////////////////////////////////////////////////////
final String _name;
final double _size;
final Color _borderColor;
final Color _foregroundColor;
final Color _backgroundColor;
final ScaleConfig _scaleConfig;
final TextStyle _textStyle;
final ImageProvider<Object>? _imageProvider;
}

View File

@ -50,6 +50,7 @@ class StyledDialog extends StatelessWidget {
required Widget child}) async => required Widget child}) async =>
showDialog<T>( showDialog<T>(
context: context, context: context,
useRootNavigator: false,
builder: (context) => StyledDialog(title: title, child: child)); builder: (context) => StyledDialog(title: title, child: child));
final String title; final String title;

View File

@ -12,15 +12,15 @@ class StyledScaffold extends StatelessWidget {
final scale = theme.extension<ScaleScheme>()!; final scale = theme.extension<ScaleScheme>()!;
final scaleConfig = theme.extension<ScaleConfig>()!; final scaleConfig = theme.extension<ScaleConfig>()!;
final scaffold = isDesktop final enableBorder = !isMobileWidth(context);
? clipBorder(
clipEnabled: true, final scaffold = clipBorder(
borderEnabled: scaleConfig.useVisualIndicators, clipEnabled: enableBorder,
borderRadius: 16 * scaleConfig.borderRadiusScale, borderEnabled: scaleConfig.useVisualIndicators,
borderColor: scale.primaryScale.border, borderRadius: 16 * scaleConfig.borderRadiusScale,
child: Scaffold(appBar: appBar, body: body, key: key)) borderColor: scale.primaryScale.border,
.paddingAll(32) child: Scaffold(appBar: appBar, body: body, key: key))
: Scaffold(appBar: appBar, body: body, key: key); .paddingAll(enableBorder ? 32 : 0);
return GestureDetector( return GestureDetector(
onTap: () => FocusManager.instance.primaryFocus?.unfocus(), onTap: () => FocusManager.instance.primaryFocus?.unfocus(),

View File

@ -1,3 +1,4 @@
export 'avatar_widget.dart';
export 'brightness_preferences.dart'; export 'brightness_preferences.dart';
export 'color_preferences.dart'; export 'color_preferences.dart';
export 'enter_password.dart'; export 'enter_password.dart';

View File

@ -27,6 +27,38 @@ extension SizeToFixExt on Widget {
); );
} }
extension FocusExt<T> on Widget {
Focus focus(
{Key? key,
FocusNode? focusNode,
FocusNode? parentNode,
bool autofocus = false,
ValueChanged<bool>? onFocusChange,
FocusOnKeyEventCallback? onKeyEvent,
bool? canRequestFocus,
bool? skipTraversal,
bool? descendantsAreFocusable,
bool? descendantsAreTraversable,
bool includeSemantics = true,
String? debugLabel}) =>
Focus(
key: key,
focusNode: focusNode,
parentNode: parentNode,
autofocus: autofocus,
onFocusChange: onFocusChange,
onKeyEvent: onKeyEvent,
canRequestFocus: canRequestFocus,
skipTraversal: skipTraversal,
descendantsAreFocusable: descendantsAreFocusable,
descendantsAreTraversable: descendantsAreTraversable,
includeSemantics: includeSemantics,
debugLabel: debugLabel,
child: this);
Focus onFocusChange(void Function(bool) onFocusChange) =>
Focus(onFocusChange: onFocusChange, child: this);
}
extension ModalProgressExt on Widget { extension ModalProgressExt on Widget {
BlurryModalProgressHUD withModalHUD(BuildContext context, bool isLoading) { BlurryModalProgressHUD withModalHUD(BuildContext context, bool isLoading) {
final theme = Theme.of(context); final theme = Theme.of(context);
@ -41,6 +73,44 @@ extension ModalProgressExt on Widget {
} }
} }
extension LabelExt on Widget {
Widget decoratorLabel(BuildContext context, String label,
{ScaleColor? scale}) {
final theme = Theme.of(context);
final scaleScheme = theme.extension<ScaleScheme>()!;
final scaleConfig = theme.extension<ScaleConfig>()!;
scale = scale ?? scaleScheme.primaryScale;
final border = scale.border;
final disabledBorder = scaleScheme.grayScale.border;
final hoverBorder = scale.hoverBorder;
final focusedErrorBorder = scaleScheme.errorScale.border;
final errorBorder = scaleScheme.errorScale.primary;
OutlineInputBorder makeBorder(Color color) => OutlineInputBorder(
borderRadius:
BorderRadius.circular(8 * scaleConfig.borderRadiusScale),
borderSide: BorderSide(color: color),
);
OutlineInputBorder makeFocusedBorder(Color color) => OutlineInputBorder(
borderRadius:
BorderRadius.circular(8 * scaleConfig.borderRadiusScale),
borderSide: BorderSide(width: 2, color: color),
);
return InputDecorator(
decoration: InputDecoration(
labelText: label,
floatingLabelStyle: TextStyle(color: hoverBorder),
border: makeBorder(border),
enabledBorder: makeBorder(border),
disabledBorder: makeBorder(disabledBorder),
focusedBorder: makeFocusedBorder(hoverBorder),
errorBorder: makeBorder(errorBorder),
focusedErrorBorder: makeFocusedBorder(focusedErrorBorder),
),
child: this);
}
}
Widget buildProgressIndicator() => Builder(builder: (context) { Widget buildProgressIndicator() => Builder(builder: (context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final scale = theme.extension<ScaleScheme>()!; final scale = theme.extension<ScaleScheme>()!;
@ -292,6 +362,23 @@ Widget styledExpandingSliver(
)); ));
} }
Widget styledHeader({required BuildContext context, required Widget child}) {
final theme = Theme.of(context);
final scale = theme.extension<ScaleScheme>()!;
final scaleConfig = theme.extension<ScaleConfig>()!;
// final textTheme = theme.textTheme;
return DecoratedBox(
decoration: ShapeDecoration(
color: scale.primaryScale.border,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(12 * scaleConfig.borderRadiusScale),
topRight:
Radius.circular(12 * scaleConfig.borderRadiusScale)))),
child: child);
}
Widget styledTitleContainer({ Widget styledTitleContainer({
required BuildContext context, required BuildContext context,
required String title, required String title,

View File

@ -37,10 +37,10 @@ packages:
dependency: "direct dev" dependency: "direct dev"
description: description:
name: async_tools name: async_tools
sha256: "375da1e5b51974a9e84469b7630f36d1361fb53d3fcc818a5a0121ab51fe0343" sha256: "9166e8fe65fc65eb79202a6d540f4de768553d78141b885f5bd3f8d7d30eef5e"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.1.4" version: "0.1.5"
bloc: bloc:
dependency: transitive dependency: transitive
description: description:
@ -53,10 +53,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: bloc_advanced_tools name: bloc_advanced_tools
sha256: "0599d860eb096c5b12457c60bdf7f66bfcb7171bc94ccf2c7c752b6a716a79f9" sha256: "2b2dd492a350e7192a933d09f15ea04d5d00e7bd3fe2a906fe629cd461ddbf94"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.1.4" version: "0.1.5"
boolean_selector: boolean_selector:
dependency: transitive dependency: transitive
description: description:

View File

@ -14,7 +14,7 @@ dependencies:
path: ../ path: ../
dev_dependencies: dev_dependencies:
async_tools: ^0.1.4 async_tools: ^0.1.5
integration_test: integration_test:
sdk: flutter sdk: flutter
lint_hard: ^4.0.0 lint_hard: ^4.0.0

View File

@ -37,10 +37,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: async_tools name: async_tools
sha256: "375da1e5b51974a9e84469b7630f36d1361fb53d3fcc818a5a0121ab51fe0343" sha256: "9166e8fe65fc65eb79202a6d540f4de768553d78141b885f5bd3f8d7d30eef5e"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.1.4" version: "0.1.5"
bloc: bloc:
dependency: "direct main" dependency: "direct main"
description: description:
@ -53,10 +53,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: bloc_advanced_tools name: bloc_advanced_tools
sha256: "0599d860eb096c5b12457c60bdf7f66bfcb7171bc94ccf2c7c752b6a716a79f9" sha256: "2b2dd492a350e7192a933d09f15ea04d5d00e7bd3fe2a906fe629cd461ddbf94"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.1.4" version: "0.1.5"
boolean_selector: boolean_selector:
dependency: transitive dependency: transitive
description: description:

View File

@ -7,9 +7,9 @@ environment:
sdk: '>=3.2.0 <4.0.0' sdk: '>=3.2.0 <4.0.0'
dependencies: dependencies:
async_tools: ^0.1.4 async_tools: ^0.1.5
bloc: ^8.1.4 bloc: ^8.1.4
bloc_advanced_tools: ^0.1.4 bloc_advanced_tools: ^0.1.5
charcode: ^1.3.1 charcode: ^1.3.1
collection: ^1.18.0 collection: ^1.18.0
equatable: ^2.0.5 equatable: ^2.0.5

View File

@ -85,10 +85,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: async_tools name: async_tools
sha256: "375da1e5b51974a9e84469b7630f36d1361fb53d3fcc818a5a0121ab51fe0343" sha256: "9166e8fe65fc65eb79202a6d540f4de768553d78141b885f5bd3f8d7d30eef5e"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.1.4" version: "0.1.5"
awesome_extensions: awesome_extensions:
dependency: "direct main" dependency: "direct main"
description: description:
@ -141,10 +141,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: bloc_advanced_tools name: bloc_advanced_tools
sha256: "0599d860eb096c5b12457c60bdf7f66bfcb7171bc94ccf2c7c752b6a716a79f9" sha256: "2b2dd492a350e7192a933d09f15ea04d5d00e7bd3fe2a906fe629cd461ddbf94"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.1.4" version: "0.1.5"
blurry_modal_progress_hud: blurry_modal_progress_hud:
dependency: "direct main" dependency: "direct main"
description: description:
@ -229,26 +229,26 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: cached_network_image name: cached_network_image
sha256: "28ea9690a8207179c319965c13cd8df184d5ee721ae2ce60f398ced1219cea1f" sha256: "4a5d8d2c728b0f3d0245f69f921d7be90cae4c2fd5288f773088672c0893f819"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.3.1" version: "3.4.0"
cached_network_image_platform_interface: cached_network_image_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: cached_network_image_platform_interface name: cached_network_image_platform_interface
sha256: "9e90e78ae72caa874a323d78fa6301b3fb8fa7ea76a8f96dc5b5bf79f283bf2f" sha256: ff0c949e323d2a1b52be73acce5b4a7b04063e61414c8ca542dbba47281630a7
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.0.0" version: "4.1.0"
cached_network_image_web: cached_network_image_web:
dependency: transitive dependency: transitive
description: description:
name: cached_network_image_web name: cached_network_image_web
sha256: "205d6a9f1862de34b93184f22b9d2d94586b2f05c581d546695e3d8f6a805cd7" sha256: "6322dde7a5ad92202e64df659241104a43db20ed594c41ca18de1014598d7996"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.2.0" version: "1.3.0"
camera: camera:
dependency: transitive dependency: transitive
description: description:
@ -261,34 +261,34 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: camera_android name: camera_android
sha256: "981654e0e56a4c735f7ecc7bd3921385eb5f7dd13deaf4a6431255d9731df01a" sha256: "134b83167cc3c83199e8d75e5bcfde677fec843e7b2ca6b754a5b0b96d00d921"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.10.9+7" version: "0.10.9+10"
camera_avfoundation: camera_avfoundation:
dependency: transitive dependency: transitive
description: description:
name: camera_avfoundation name: camera_avfoundation
sha256: "7d021e8cd30d9b71b8b92b4ad669e80af432d722d18d6aac338572754a786c15" sha256: b5093a82537b64bb88d4244f8e00b5ba69e822a5994f47b31d11400e1db975e5
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.9.16" version: "0.9.17+1"
camera_platform_interface: camera_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: camera_platform_interface name: camera_platform_interface
sha256: a250314a48ea337b35909a4c9d5416a208d736dcb01d0b02c6af122be66660b0 sha256: b3ede1f171532e0d83111fe0980b46d17f1aa9788a07a2fbed07366bbdbb9061
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.7.4" version: "2.8.0"
camera_web: camera_web:
dependency: transitive dependency: transitive
description: description:
name: camera_web name: camera_web
sha256: "9e9aba2fbab77ce2472924196ff8ac4dd8f9126c4f9a3096171cd1d870d6b26c" sha256: b9235ec0a2ce949daec546f1f3d86f05c3921ed31c7d9ab6b7c03214d152fc2d
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.3.3" version: "0.3.4"
change_case: change_case:
dependency: "direct main" dependency: "direct main"
description: description:
@ -461,10 +461,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: expansion_tile_group name: expansion_tile_group
sha256: "6918433891481c7d98cbc604d7b4c93509986e8134d52940853301ad6fbff404" sha256: "47615665d4e610dee0b6362de9e81003b56b150b5765ea5444a091762b5dc7d5"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.2.4" version: "1.3.0"
fast_immutable_collections: fast_immutable_collections:
dependency: "direct main" dependency: "direct main"
description: description:
@ -530,10 +530,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: flutter_cache_manager name: flutter_cache_manager
sha256: "395d6b7831f21f3b989ebedbb785545932adb9afe2622c1ffacf7f4b53a7e544" sha256: a77f77806a790eb9ba0118a5a3a936e81c4fea2b61533033b2b0c3d50bbde5ea
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.3.2" version: "3.4.0"
flutter_chat_types: flutter_chat_types:
dependency: "direct main" dependency: "direct main"
description: description:
@ -608,10 +608,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: flutter_plugin_android_lifecycle name: flutter_plugin_android_lifecycle
sha256: c6b0b4c05c458e1c01ad9bcc14041dd7b1f6783d487be4386f793f47a8a4d03e sha256: "9d98bd47ef9d34e803d438f17fd32b116d31009f534a6fa5ce3a1167f189a6de"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.20" version: "2.0.21"
flutter_shaders: flutter_shaders:
dependency: transitive dependency: transitive
description: description:
@ -624,10 +624,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: flutter_slidable name: flutter_slidable
sha256: "673403d2eeef1f9e8483bd6d8d92aae73b1d8bd71f382bc3930f699c731bc27c" sha256: "2c5611c0b44e20d180e4342318e1bbc28b0a44ad2c442f5df16962606fd3e8e3"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.0" version: "3.1.1"
flutter_spinkit: flutter_spinkit:
dependency: "direct main" dependency: "direct main"
description: description:
@ -677,10 +677,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: form_builder_validators name: form_builder_validators
sha256: "475853a177bfc832ec12551f752fd0001278358a6d42d2364681ff15f48f67cf" sha256: c61ed7b1deecf0e1ebe49e2fa79e3283937c5a21c7e48e3ed9856a4a14e1191a
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "10.0.1" version: "11.0.0"
freezed: freezed:
dependency: "direct dev" dependency: "direct dev"
description: description:
@ -693,10 +693,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: freezed_annotation name: freezed_annotation
sha256: f54946fdb1fa7b01f780841937b1a80783a20b393485f3f6cdf336fd6f4705f2 sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.2" version: "2.4.4"
frontend_server_client: frontend_server_client:
dependency: transitive dependency: transitive
description: description:
@ -733,18 +733,18 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: go_router name: go_router
sha256: cdae1b9c8bd7efadcef6112e81c903662ef2ce105cbd220a04bbb7c3425b5554 sha256: "39dd52168d6c59984454183148dc3a5776960c61083adfc708cc79a7b3ce1ba8"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "14.2.0" version: "14.2.1"
graphs: graphs:
dependency: transitive dependency: transitive
description: description:
name: graphs name: graphs
sha256: aedc5a15e78fc65a6e23bcd927f24c64dd995062bcd1ca6eda65a3cff92a4d19 sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.1" version: "2.3.2"
hive: hive:
dependency: transitive dependency: transitive
description: description:
@ -765,10 +765,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: http name: http
sha256: "761a297c042deedc1ffbb156d6e2af13886bb305c2a343a4d972504cd67dd938" sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.2.1" version: "1.2.2"
http_multi_server: http_multi_server:
dependency: transitive dependency: transitive
description: description:
@ -949,10 +949,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: octo_image name: octo_image
sha256: "45b40f99622f11901238e18d48f5f12ea36426d8eced9f4cbf58479c7aa2430d" sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.0" version: "2.1.0"
package_config: package_config:
dependency: transitive dependency: transitive
description: description:
@ -965,18 +965,18 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: package_info_plus name: package_info_plus
sha256: b93d8b4d624b4ea19b0a5a208b2d6eff06004bc3ce74c06040b120eeadd00ce0 sha256: "4de6c36df77ffbcef0a5aefe04669d33f2d18397fea228277b852a2d4e58e860"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "8.0.0" version: "8.0.1"
package_info_plus_platform_interface: package_info_plus_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: package_info_plus_platform_interface name: package_info_plus_platform_interface
sha256: f49918f3433a3146047372f9d4f1f847511f2acd5cd030e1f44fe5a50036b70e sha256: ac1f4a4847f1ade8e6a87d1f39f5d7c67490738642e2542f559ec38c37489a66
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.0" version: "3.0.1"
pasteboard: pasteboard:
dependency: "direct main" dependency: "direct main"
description: description:
@ -1005,18 +1005,18 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: path_provider name: path_provider
sha256: c9e7d3a4cd1410877472158bee69963a4579f78b68c65a2b7d40d1a7a88bb161 sha256: fec0d61223fba3154d87759e3cc27fe2c8dc498f6386c6d6fc80d1afdd1bf378
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.3" version: "2.1.4"
path_provider_android: path_provider_android:
dependency: transitive dependency: transitive
description: description:
name: path_provider_android name: path_provider_android
sha256: bca87b0165ffd7cdb9cad8edd22d18d2201e886d9a9f19b4fb3452ea7df3a72a sha256: "490539678396d4c3c0b06efdaab75ae60675c3e0c66f72bc04c2e2c1e0e2abeb"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.6" version: "2.2.9"
path_provider_foundation: path_provider_foundation:
dependency: transitive dependency: transitive
description: description:
@ -1045,10 +1045,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: path_provider_windows name: path_provider_windows
sha256: "8bc9f22eee8690981c22aa7fc602f5c85b497a6fb2ceb35ee5a5e5ed85ad8170" sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.1" version: "2.3.0"
pdf: pdf:
dependency: "direct main" dependency: "direct main"
description: description:
@ -1173,10 +1173,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: qr name: qr
sha256: "64957a3930367bf97cc211a5af99551d630f2f4625e38af10edd6b19131b64b3" sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.1" version: "3.0.2"
qr_code_dart_scan: qr_code_dart_scan:
dependency: "direct main" dependency: "direct main"
description: description:
@ -1229,10 +1229,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: rxdart name: rxdart
sha256: "0c7c0cedd93788d996e33041ffecda924cc54389199cde4e6a34b440f50044cb" sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.27.7" version: "0.28.0"
screen_retriever: screen_retriever:
dependency: transitive dependency: transitive
description: description:
@ -1260,11 +1260,12 @@ packages:
searchable_listview: searchable_listview:
dependency: "direct main" dependency: "direct main"
description: description:
name: searchable_listview path: "."
sha256: "5e547f2a3f88f99a798cfe64882cfce51496d8f3177bea4829dd7bcf3fa8910d" ref: main
url: "https://pub.dev" resolved-ref: db0f7b6f1baec0250ecba82f3d161bac1cf23d7d
source: hosted url: "https://gitlab.com/veilid/Searchable-Listview.git"
version: "2.14.0" source: git
version: "2.14.1"
share_plus: share_plus:
dependency: "direct main" dependency: "direct main"
description: description:
@ -1285,58 +1286,58 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: shared_preferences name: shared_preferences
sha256: d3bbe5553a986e83980916ded2f0b435ef2e1893dfaa29d5a7a790d0eca12180 sha256: c272f9cabca5a81adc9b0894381e9c1def363e980f960fa903c604c471b22f68
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.3" version: "2.3.1"
shared_preferences_android: shared_preferences_android:
dependency: transitive dependency: transitive
description: description:
name: shared_preferences_android name: shared_preferences_android
sha256: "93d0ec9dd902d85f326068e6a899487d1f65ffcd5798721a95330b26c8131577" sha256: "041be4d9d2dc6079cf342bc8b761b03787e3b71192d658220a56cac9c04a0294"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.3" version: "2.3.0"
shared_preferences_foundation: shared_preferences_foundation:
dependency: transitive dependency: transitive
description: description:
name: shared_preferences_foundation name: shared_preferences_foundation
sha256: "0a8a893bf4fd1152f93fec03a415d11c27c74454d96e2318a7ac38dd18683ab7" sha256: "671e7a931f55a08aa45be2a13fe7247f2a41237897df434b30d2012388191833"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.0" version: "2.5.0"
shared_preferences_linux: shared_preferences_linux:
dependency: transitive dependency: transitive
description: description:
name: shared_preferences_linux name: shared_preferences_linux
sha256: "9f2cbcf46d4270ea8be39fa156d86379077c8a5228d9dfdb1164ae0bb93f1faa" sha256: "2ba0510d3017f91655b7543e9ee46d48619de2a2af38e5c790423f7007c7ccc1"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.2" version: "2.4.0"
shared_preferences_platform_interface: shared_preferences_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: shared_preferences_platform_interface name: shared_preferences_platform_interface
sha256: "22e2ecac9419b4246d7c22bfbbda589e3acf5c0351137d87dd2939d984d37c3b" sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.2" version: "2.4.1"
shared_preferences_web: shared_preferences_web:
dependency: transitive dependency: transitive
description: description:
name: shared_preferences_web name: shared_preferences_web
sha256: "9aee1089b36bd2aafe06582b7d7817fd317ef05fc30e6ba14bff247d0933042a" sha256: "3a293170d4d9403c3254ee05b84e62e8a9b3c5808ebd17de6a33fe9ea6457936"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.0" version: "2.4.0"
shared_preferences_windows: shared_preferences_windows:
dependency: transitive dependency: transitive
description: description:
name: shared_preferences_windows name: shared_preferences_windows
sha256: "841ad54f3c8381c480d0c9b508b89a34036f512482c407e6df7a9c4aa2ef8f59" sha256: "398084b47b7f92110683cac45c6dc4aae853db47e470e5ddcd52cab7f7196ab2"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.2" version: "2.4.0"
shelf: shelf:
dependency: transitive dependency: transitive
description: description:
@ -1471,6 +1472,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.11.1" version: "1.11.1"
star_menu:
dependency: "direct main"
description:
name: star_menu
sha256: f29c7d255677c49ec2412ec2d17220d967f54b72b9e6afc5688fe122ea4d1d78
url: "https://pub.dev"
source: hosted
version: "4.0.1"
stream_channel: stream_channel:
dependency: transitive dependency: transitive
description: description:
@ -1491,10 +1500,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: string_scanner name: string_scanner
sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.2.0" version: "1.3.0"
synchronized: synchronized:
dependency: transitive dependency: transitive
description: description:
@ -1587,18 +1596,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_android name: url_launcher_android
sha256: ceb2625f0c24ade6ef6778d1de0b2e44f2db71fded235eb52295247feba8c5cf sha256: "94d8ad05f44c6d4e2ffe5567ab4d741b82d62e3c8e288cc1fcea45965edf47c9"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.3.3" version: "6.3.8"
url_launcher_ios: url_launcher_ios:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_ios name: url_launcher_ios
sha256: "7068716403343f6ba4969b4173cbf3b84fc768042124bc2c011e5d782b24fe89" sha256: e43b677296fadce447e987a2f519dcf5f6d1e527dc35d01ffab4fff5b8a7063e
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.3.0" version: "6.3.1"
url_launcher_linux: url_launcher_linux:
dependency: transitive dependency: transitive
description: description:
@ -1635,18 +1644,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_windows name: url_launcher_windows
sha256: ecf9725510600aa2bb6d7ddabe16357691b6d2805f66216a97d1b881e21beff7 sha256: "49c10f879746271804767cb45551ec5592cdab00ee105c06dddde1a98f73b185"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.1" version: "3.1.2"
uuid: uuid:
dependency: "direct main" dependency: "direct main"
description: description:
name: uuid name: uuid
sha256: "814e9e88f21a176ae1359149021870e87f7cddaf633ab678a5d2b0bff7fd1ba8" sha256: "83d37c7ad7aaf9aa8e275490669535c8080377cfa7a7004c24dfac53afffaa90"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.4.0" version: "4.4.2"
value_layout_builder: value_layout_builder:
dependency: transitive dependency: transitive
description: description:
@ -1729,26 +1738,26 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: web_socket name: web_socket
sha256: "24301d8c293ce6fe327ffe6f59d8fd8834735f0ec36e4fd383ec7ff8a64aa078" sha256: "3c12d96c0c9a4eec095246debcea7b86c0324f22df69893d538fcc6f1b8cce83"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.1.5" version: "0.1.6"
web_socket_channel: web_socket_channel:
dependency: transitive dependency: transitive
description: description:
name: web_socket_channel name: web_socket_channel
sha256: a2d56211ee4d35d9b344d9d4ce60f362e4f5d1aafb988302906bd732bc731276 sha256: "9f187088ed104edd8662ca07af4b124465893caf063ba29758f97af57e61da8f"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.0" version: "3.0.1"
win32: win32:
dependency: transitive dependency: transitive
description: description:
name: win32 name: win32
sha256: a79dbe579cb51ecd6d30b17e0cae4e0ea15e2c0e66f69ad4198f22a6789e94f4 sha256: "015002c060f1ae9f41a818f2d5640389cc05283e368be19dc8d77cecb43c40c9"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.5.1" version: "5.5.3"
window_manager: window_manager:
dependency: "direct main" dependency: "direct main"
description: description:

View File

@ -14,12 +14,12 @@ dependencies:
animated_theme_switcher: ^2.0.10 animated_theme_switcher: ^2.0.10
ansicolor: ^2.0.2 ansicolor: ^2.0.2
archive: ^3.6.1 archive: ^3.6.1
async_tools: ^0.1.4 async_tools: ^0.1.5
awesome_extensions: ^2.0.16 awesome_extensions: ^2.0.16
badges: ^3.1.2 badges: ^3.1.2
basic_utils: ^5.7.0 basic_utils: ^5.7.0
bloc: ^8.1.4 bloc: ^8.1.4
bloc_advanced_tools: ^0.1.4 bloc_advanced_tools: ^0.1.5
blurry_modal_progress_hud: ^1.1.1 blurry_modal_progress_hud: ^1.1.1
change_case: ^2.1.0 change_case: ^2.1.0
charcode: ^1.3.1 charcode: ^1.3.1
@ -52,7 +52,7 @@ dependencies:
flutter_svg: ^2.0.10+1 flutter_svg: ^2.0.10+1
flutter_translate: ^4.1.0 flutter_translate: ^4.1.0
flutter_zoom_drawer: ^3.2.0 flutter_zoom_drawer: ^3.2.0
form_builder_validators: ^10.0.1 form_builder_validators: ^11.0.0
freezed_annotation: ^2.4.1 freezed_annotation: ^2.4.1
go_router: ^14.1.4 go_router: ^14.1.4
hydrated_bloc: ^9.1.5 hydrated_bloc: ^9.1.5
@ -81,7 +81,10 @@ dependencies:
reorderable_grid: ^1.0.10 reorderable_grid: ^1.0.10
screenshot: ^3.0.0 screenshot: ^3.0.0
scroll_to_index: ^3.0.1 scroll_to_index: ^3.0.1
searchable_listview: ^2.14.0 searchable_listview:
git:
url: https://gitlab.com/veilid/Searchable-Listview.git
ref: main
share_plus: ^9.0.0 share_plus: ^9.0.0
shared_preferences: ^2.2.3 shared_preferences: ^2.2.3
signal_strength_indicator: ^0.4.1 signal_strength_indicator: ^0.4.1
@ -94,6 +97,7 @@ dependencies:
ref: main ref: main
split_view: ^3.2.1 split_view: ^3.2.1
stack_trace: ^1.11.1 stack_trace: ^1.11.1
star_menu: ^4.0.1
stream_transform: ^2.1.0 stream_transform: ^2.1.0
transitioned_indexed_stack: ^1.0.2 transitioned_indexed_stack: ^1.0.2
url_launcher: ^6.3.0 url_launcher: ^6.3.0
@ -112,6 +116,8 @@ dependencies:
# path: ../dart_async_tools # path: ../dart_async_tools
# bloc_advanced_tools: # bloc_advanced_tools:
# path: ../bloc_advanced_tools # path: ../bloc_advanced_tools
# searchable_listview:
# path: ../Searchable-Listview
# flutter_chat_ui: # flutter_chat_ui:
# path: ../flutter_chat_ui # path: ../flutter_chat_ui
@ -158,6 +164,8 @@ flutter:
- assets/images/title.svg - assets/images/title.svg
- assets/images/vlogo.svg - assets/images/vlogo.svg
- assets/images/ellet.png - assets/images/ellet.png
- assets/images/toilet.png
- assets/images/handshake.png
# Printing # Printing
- assets/js/pdf/3.2.146/pdf.min.js - assets/js/pdf/3.2.146/pdf.min.js
# Sounds # Sounds