Profile Images
continuous-integration/drone/pr Build is pending Details

This commit is contained in:
Sarah Jamie Lewis 2022-02-04 16:57:31 -08:00
parent e22db92dc1
commit 3d85883f8e
10 changed files with 119 additions and 39 deletions

View File

@ -77,7 +77,7 @@ class FlwtchWorker(context: Context, parameters: WorkerParameters) :
}
val loader = FlutterInjector.instance().flutterLoader()
val key = loader.getLookupKeyForAsset("assets/" + data.getString("Picture"))//"assets/profiles/001-centaur.png")
val key = loader.getLookupKeyForAsset("assets/" + data.getString("picture"))//"assets/profiles/001-centaur.png")
val fh = applicationContext.assets.open(key)

View File

@ -106,7 +106,7 @@ class CwtchNotifier {
profileCN.getProfile(data["ProfileOnion"])?.contactList.add(ContactInfoState(data["ProfileOnion"], int.parse(data["ConversationID"]), data["GroupID"],
blocked: false, // we created
accepted: true, // we created
imagePath: data["PicturePath"],
imagePath: data["picture"],
nickname: data["GroupName"],
status: status,
server: data["GroupServer"],
@ -147,7 +147,7 @@ class CwtchNotifier {
var messageID = int.parse(data["Index"]);
var timestamp = DateTime.tryParse(data['TimestampReceived'])!;
var senderHandle = data['RemotePeer'];
var senderImage = data['Picture'];
var senderImage = data['picture'];
var isAuto = data['Auto'] == "true";
String? contenthash = data['ContentHash'];
var selectedProfile = appState.selectedProfile == data["ProfileOnion"];
@ -199,7 +199,7 @@ class CwtchNotifier {
if (data["ProfileOnion"] != data["RemotePeer"]) {
var idx = int.parse(data["Index"]);
var senderHandle = data['RemotePeer'];
var senderImage = data['Picture'];
var senderImage = data['picture'];
var timestampSent = DateTime.tryParse(data['TimestampSent'])!;
var contact = profileCN.getProfile(data["ProfileOnion"])?.contactList.getContact(identifier);
var currentTotal = contact!.totalMessages;
@ -301,7 +301,7 @@ class CwtchNotifier {
profileCN.getProfile(data["ProfileOnion"])?.contactList.add(ContactInfoState(data["ProfileOnion"], identifier, groupInvite["GroupID"],
blocked: false, // NewGroup only issued on accepting invite
accepted: true, // NewGroup only issued on accepting invite
imagePath: data["PicturePath"],
imagePath: data["picture"],
nickname: groupInvite["GroupName"],
server: groupInvite["ServerHost"],
status: status,
@ -322,15 +322,28 @@ class CwtchNotifier {
profileCN.getProfile(data["ProfileOnion"])?.contactList.resort();
break;
case "NewRetValMessageFromPeer":
if (data["Path"] == "profile.name") {
if (data["Path"] == "profile.name" && data["Exists"] == "true") {
if (data["Data"].toString().trim().length > 0) {
// Update locally on the UI...
if (profileCN.getProfile(data["ProfileOnion"])?.contactList.findContact(data["RemotePeer"]) != null) {
profileCN.getProfile(data["ProfileOnion"])?.contactList.findContact(data["RemotePeer"])!.nickname = data["Data"];
}
}
} else if (data['Path'] == "profile.picture") {
// Not yet..
} else if (data['Path'] == "profile.custom-profile-image" && data["Exists"] == "true") {
EnvironmentConfig.debugLog("received ret val of custom profile image: $data");
String fileKey = data['Data'];
String filePath = data['FilePath'];
bool downloaded = data['FileDownloadFinished'] == "true";
if (downloaded) {
if (profileCN.getProfile(data["ProfileOnion"])?.contactList.findContact(data["RemotePeer"]) != null) {
profileCN.getProfile(data["ProfileOnion"])?.contactList.findContact(data["RemotePeer"])!.imagePath = filePath;
}
} else {
var contact = profileCN.getProfile(data["ProfileOnion"])?.contactList.findContact(data["RemotePeer"]);
if (contact != null) {
profileCN.getProfile(data["ProfileOnion"])?.waitForDownloadComplete(contact.identifier, fileKey);
}
}
} else {
EnvironmentConfig.debugLog("unhandled ret val event: ${data['Path']}");
}

View File

@ -17,6 +17,7 @@ class ProfileInfoState extends ChangeNotifier {
int _unreadMessages = 0;
bool _online = false;
Map<String, FileDownloadProgress> _downloads = Map<String, FileDownloadProgress>();
Map<String, int> _downloadTriggers = Map<String, int>();
// assume profiles are encrypted...this will be set to false
// in the constructor if the profile is encrypted with the defacto password.
@ -178,22 +179,14 @@ class ProfileInfoState extends ChangeNotifier {
this._contacts.resort();
}
void newMessage(int identifier, int messageID, DateTime timestamp, String senderHandle, String senderImage, bool isAuto, String data, String? contenthash, bool selectedProfile, bool selectedConversation) {
void newMessage(
int identifier, int messageID, DateTime timestamp, String senderHandle, String senderImage, bool isAuto, String data, String? contenthash, bool selectedProfile, bool selectedConversation) {
if (!selectedProfile) {
unreadMessages++;
notifyListeners();
}
contactList.newMessage(
identifier,
messageID,
timestamp,
senderHandle,
senderImage,
isAuto,
data,
contenthash,
selectedConversation);
contactList.newMessage(identifier, messageID, timestamp, senderHandle, senderImage, isAuto, data, contenthash, selectedConversation);
}
void downloadInit(String fileKey, int numChunks) {
@ -232,6 +225,15 @@ class ProfileInfoState extends ChangeNotifier {
// so setting numChunks correctly shouldn't matter
this.downloadInit(fileKey, 1);
}
// Update the contact with a custom profile image if we are
// waiting for one...
if (this._downloadTriggers.containsKey(fileKey)) {
int identifier = this._downloadTriggers[fileKey]!;
this.contactList.getContact(identifier)!.imagePath = finalPath;
notifyListeners();
}
// only update if different
if (!this._downloads[fileKey]!.complete) {
this._downloads[fileKey]!.timeEnd = DateTime.now();
@ -308,4 +310,9 @@ class ProfileInfoState extends ChangeNotifier {
}
return prettyBytes((bytes / seconds).round()) + "/s";
}
void waitForDownloadComplete(int identifier, String fileKey) {
_downloadTriggers[fileKey] = identifier;
notifyListeners();
}
}

View File

@ -28,7 +28,5 @@ class ProfileListState extends ChangeNotifier {
notifyListeners();
}
int generateUnreadCount(String selectedProfile) => _profiles.where( (p) => p.onion != selectedProfile ).fold(0, (i, p) => i + p.unreadMessages);
int generateUnreadCount(String selectedProfile) => _profiles.where((p) => p.onion != selectedProfile).fold(0, (i, p) => i + p.unreadMessages);
}

View File

@ -4,7 +4,9 @@ import 'dart:math';
import 'package:cwtch/config.dart';
import 'package:cwtch/cwtch/cwtch.dart';
import 'package:cwtch/models/appstate.dart';
import 'package:cwtch/models/profile.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:cwtch/widgets/buttontextfield.dart';
@ -66,6 +68,37 @@ class _AddEditProfileViewState extends State<AddEditProfileView> {
});
}
void _showFilePicker(BuildContext ctx) async {
// only allow one file picker at a time
// note: ideally we would destroy file picker when leaving a conversation
// but we don't currently have that option.
// we need to store AppState in a variable because ctx might be destroyed
// while awaiting for pickFiles.
var appstate = Provider.of<AppState>(ctx, listen: false);
appstate.disableFilePicker = true;
// currently lockParentWindow only works on Windows...
FilePickerResult? result = await FilePicker.platform.pickFiles(lockParentWindow: true);
appstate.disableFilePicker = false;
if (result != null && result.files.first.path != null) {
File file = File(result.files.first.path!);
// We have a maximum number of bytes we can represent in terms of
// a manifest (see : https://git.openprivacy.ca/cwtch.im/cwtch/src/branch/master/protocol/files/manifest.go#L25)
if (file.lengthSync() <= 10737418240) {
var profile = Provider.of<ProfileInfoState>(context, listen: false).onion;
// Share this image publically (conversation handle == -1)
Provider.of<FlwtchState>(context, listen: false).cwtch.ShareFile(profile, -1, file.path);
// update the image cache locally
Provider.of<ProfileInfoState>(context, listen: false).imagePath = file.path;
} else {
final snackBar = SnackBar(
content: Text(AppLocalizations.of(context)!.msgFileTooBig),
duration: Duration(seconds: 4),
);
ScaffoldMessenger.of(ctx).showSnackBar(snackBar);
}
}
}
// A few implementation notes
// We use Visibility to hide optional structures when they are not requested.
// We used SizedBox for inter-widget height padding in columns, otherwise elements can render a little too close together.
@ -89,14 +122,20 @@ class _AddEditProfileViewState extends State<AddEditProfileView> {
Visibility(
visible: Provider.of<ProfileInfoState>(context).onion.isNotEmpty,
child: Row(mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[
ProfileImage(
imagePath: Provider.of<ProfileInfoState>(context).imagePath,
diameter: 120,
maskOut: false,
border: theme.theme.portraitOnlineBorderColor,
badgeTextColor: Colors.red,
badgeColor: Colors.red,
)
MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () {
_showFilePicker(context);
},
child: ProfileImage(
imagePath: Provider.of<ProfileInfoState>(context).imagePath,
diameter: 120,
maskOut: false,
border: theme.theme.portraitOnlineBorderColor,
badgeTextColor: Colors.red,
badgeColor: Colors.red,
)))
])),
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
CwtchLabel(label: AppLocalizations.of(context)!.displayNameLabel),

View File

@ -86,11 +86,10 @@ class _ContactsViewState extends State<ContactsView> {
Navigator.of(context).pop();
},
),
StreamBuilder<bool>(
stream: Provider.of<AppState>(context).getUnreadProfileNotifyStream(),
builder: (BuildContext context, AsyncSnapshot<bool> unreadCountSnapshot) {
int unreadCount = Provider.of<ProfileListState>(context).generateUnreadCount(Provider.of<AppState>(context).selectedProfile ?? "") ;
int unreadCount = Provider.of<ProfileListState>(context).generateUnreadCount(Provider.of<AppState>(context).selectedProfile ?? "");
return Visibility(
visible: unreadCount > 0,

View File

@ -152,6 +152,7 @@ class MessageRowState extends State<MessageRow> with SingleTickerProviderStateMi
];
} else {
var contact = Provider.of<ContactInfoState>(context);
ContactInfoState? sender = Provider.of<ProfileInfoState>(context).contactList.findContact(Provider.of<MessageMetadata>(context).senderHandle);
Widget wdgPortrait = GestureDetector(
onTap: !isGroup
? null
@ -162,7 +163,8 @@ class MessageRowState extends State<MessageRow> with SingleTickerProviderStateMi
padding: EdgeInsets.all(4.0),
child: ProfileImage(
diameter: 48.0,
imagePath: Provider.of<MessageMetadata>(context).senderImage ?? contact.imagePath,
// default to the contact image...otherwise use a derived sender image...
imagePath: sender?.imagePath ?? Provider.of<MessageMetadata>(context).senderImage!,
border: contact.status == "Authenticated" ? Provider.of<Settings>(context).theme.portraitOnlineBorderColor : Provider.of<Settings>(context).theme.portraitOfflineBorderColor,
badgeTextColor: Colors.red,
badgeColor: Colors.red,

View File

@ -1,3 +1,5 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:cwtch/themes/opaque.dart';
import 'package:provider/provider.dart';
@ -23,8 +25,11 @@ class ProfileImage extends StatefulWidget {
class _ProfileImageState extends State<ProfileImage> {
@override
Widget build(BuildContext context) {
var image = Image(
image: AssetImage("assets/" + widget.imagePath),
var file = new File(widget.imagePath);
var image = Image.file(
file,
cacheWidth: 512,
cacheHeight: 512,
filterQuality: FilterQuality.medium,
// We need some theme specific blending here...we might want to consider making this a theme level attribute
colorBlendMode: !widget.maskOut
@ -36,6 +41,21 @@ class _ProfileImageState extends State<ProfileImage> {
isAntiAlias: true,
width: widget.diameter,
height: widget.diameter,
errorBuilder: (context, error, stackTrace) {
// on android the above will fail for asset images, in which case try to load them the original way
return Image.asset(widget.imagePath,
filterQuality: FilterQuality.medium,
// We need some theme specific blending here...we might want to consider making this a theme level attribute
colorBlendMode: !widget.maskOut
? Provider.of<Settings>(context).theme.mode == mode_dark
? BlendMode.softLight
: BlendMode.darken
: BlendMode.srcOut,
color: Provider.of<Settings>(context).theme.portraitBackgroundColor,
isAntiAlias: true,
width: widget.diameter,
height: widget.diameter);
},
);
return RepaintBoundary(

View File

@ -105,11 +105,12 @@ class _ProfileRowState extends State<ProfileRow> {
void _pushEditProfile({onion: "", displayName: "", profileImage: "", encrypted: true}) {
Provider.of<ErrorHandler>(context, listen: false).reset();
Navigator.of(context).push(MaterialPageRoute<void>(
builder: (BuildContext context) {
builder: (BuildContext bcontext) {
var profile = Provider.of<FlwtchState>(bcontext).profs.getProfile(onion)!;
return MultiProvider(
providers: [
ChangeNotifierProvider<ProfileInfoState>(
create: (_) => ProfileInfoState(onion: onion, nickname: displayName, imagePath: profileImage, encrypted: encrypted),
ChangeNotifierProvider<ProfileInfoState>.value(
value: profile,
),
],
builder: (context, widget) => AddEditProfileView(),

View File

@ -8,7 +8,8 @@ doNothing(String x) {}
// Provides a styled Text Field for use in Form Widgets.
// Callers must provide a text controller, label helper text and a validator.
class CwtchTextField extends StatefulWidget {
CwtchTextField({required this.controller, this.hintText = "", this.validator, this.autofocus = false, this.onChanged = doNothing, this.number = false, this.multiLine = false, this.key, this.testKey});
CwtchTextField(
{required this.controller, this.hintText = "", this.validator, this.autofocus = false, this.onChanged = doNothing, this.number = false, this.multiLine = false, this.key, this.testKey});
final TextEditingController controller;
final String hintText;
final FormFieldValidator? validator;