summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorNiels De Graef <nielsdegraef@gmail.com>2022-08-16 12:36:08 +0200
committerNiels De Graef <nielsdegraef@gmail.com>2022-09-03 08:50:19 +0200
commit1d44c11483e3d00611c0beed9afc8f2c9facc3a8 (patch)
tree6a38fecd4acca16fb8c6d1b4322b2e10fbe0d5ad /src
parent4d0710b8a6c7a3cd2ee0311b62f5e2707c34c305 (diff)
downloadgnome-contacts-1d44c11483e3d00611c0beed9afc8f2c9facc3a8.tar.gz
Introduce the concept of Contacts.Chunk
This commit introduces a new class `Contacts.Chunk`. Just like libfolks, we see a contact as a collection of data, or to word it differently: a collection built up from "chunks" of information. The net result of adding this concept adds quite a bit of lines of code, but it does have some major benefits: * Rather than stuffing new properties into yet another if-else spread out over multiple places in contacts-utils (and quite a bit of other files), we can create a new subclass of `Contacts.Chunk` * This also goes for property-specific logic, which we can consolidate within their appropriate classes/files. * All of our logic is now unit-testable In the future, this would allow for more cleanups/features: * We can put the serialization code for each property inside the `Contacts.Chunk` * We can extend ContactSheet to show a vCard's information, before actually importing it into a Folks.Individual. * We can write unit tests on the set of chunks, rather than regularly having to deal with yet another regression in e.g. the birthday editor.
Diffstat (limited to 'src')
-rw-r--r--src/contacts-avatar-selector.vala57
-rw-r--r--src/contacts-avatar.vala73
-rw-r--r--src/contacts-chunk-empty-filter.vala50
-rw-r--r--src/contacts-chunk-filter.vala68
-rw-r--r--src/contacts-chunk-property-filter.vala77
-rw-r--r--src/contacts-chunk-sorter.vala73
-rw-r--r--src/contacts-contact-editor.vala779
-rw-r--r--src/contacts-contact-pane.vala170
-rw-r--r--src/contacts-contact-sheet.vala548
-rw-r--r--src/contacts-editor-persona.vala165
-rw-r--r--src/contacts-editor-property.vala761
-rw-r--r--src/contacts-fake-persona-store.vala612
-rw-r--r--src/contacts-main-window.vala2
-rw-r--r--src/contacts-persona-sorter.vala22
-rw-r--r--src/contacts-type-combo.vala10
-rw-r--r--src/contacts-type-descriptor.vala29
-rw-r--r--src/contacts-typeset.vala15
-rw-r--r--src/contacts-utils.vala147
-rw-r--r--src/core/contacts-addresses-chunk.vala138
-rw-r--r--src/core/contacts-alias-chunk.vala57
-rw-r--r--src/core/contacts-avatar-chunk.vala53
-rw-r--r--src/core/contacts-bin-chunk.vala171
-rw-r--r--src/core/contacts-birthday-chunk.vala62
-rw-r--r--src/core/contacts-chunk.vala63
-rw-r--r--src/core/contacts-contact.vala303
-rw-r--r--src/core/contacts-email-addresses-chunk.vala93
-rw-r--r--src/core/contacts-full-name-chunk.vala61
-rw-r--r--src/core/contacts-im-addresses-chunk.vala99
-rw-r--r--src/core/contacts-nickname-chunk.vala60
-rw-r--r--src/core/contacts-notes-chunk.vala85
-rw-r--r--src/core/contacts-phones-chunk.vala97
-rw-r--r--src/core/contacts-roles-chunk.vala94
-rw-r--r--src/core/contacts-structured-name-chunk.vala69
-rw-r--r--src/core/contacts-urls-chunk.vala95
-rw-r--r--src/meson.build26
35 files changed, 3084 insertions, 2200 deletions
diff --git a/src/contacts-avatar-selector.vala b/src/contacts-avatar-selector.vala
index dfdb7c5..cfe92aa 100644
--- a/src/contacts-avatar-selector.vala
+++ b/src/contacts-avatar-selector.vala
@@ -36,16 +36,15 @@ private class Contacts.Thumbnail : Gtk.FlowBoxChild {
this.set_child (avatar);
}
- public Thumbnail.for_persona (Persona persona) {
- Gdk.Pixbuf? pixbuf = null;
- unowned var details = persona as AvatarDetails;
- if (details != null && details.avatar != null) {
- try {
- var stream = details.avatar.load (MAIN_SIZE, null);
- pixbuf = new Gdk.Pixbuf.from_stream (stream);
- } catch (Error e) {
- debug ("Couldn't create frame for persona '%s': %s", persona.display_id, e.message);
- }
+ public Thumbnail.for_chunk (AvatarChunk chunk)
+ requires (chunk.avatar != null) {
+
+ Gdk.Pixbuf? pixbuf = null;
+ try {
+ var stream = chunk.avatar.load (MAIN_SIZE, null);
+ pixbuf = new Gdk.Pixbuf.from_stream (stream);
+ } catch (Error e) {
+ debug ("Couldn't create thumbnail for chunk: %s", e.message);
}
this (pixbuf);
}
@@ -73,7 +72,7 @@ public class Contacts.AvatarSelector : Gtk.Dialog {
const string AVATAR_BUTTON_CSS_NAME = "avatar-button";
- private unowned Individual individual;
+ public unowned Contact contact { get; construct set; }
[GtkChild]
private unowned Gtk.FlowBox thumbnail_grid;
@@ -89,9 +88,8 @@ public class Contacts.AvatarSelector : Gtk.Dialog {
private set { this._selected_avatar = value; }
}
- public AvatarSelector (Individual? individual, Gtk.Window? window = null) {
- Object (transient_for: window, use_header_bar: 1);
- this.individual = individual;
+ public AvatarSelector (Contact contact, Gtk.Window? window = null) {
+ Object (contact: contact, transient_for: window, use_header_bar: 1);
this.thumbnail_grid.selected_children_changed.connect (on_thumbnails_selected);
this.thumbnail_grid.child_activated.connect (on_thumbnail_activated);
@@ -149,28 +147,27 @@ public class Contacts.AvatarSelector : Gtk.Dialog {
return pixbuf.scale_simple (w, h, Gdk.InterpType.HYPER);
}
- /**
- * Saves the selected avatar as the one that should be used.
- *
- * You should probably only do this after the "response" signal
- * (with ResponseType.ACCEPT)
- */
- public async void save_selection () throws GLib.Error {
- debug ("Saving selected avatar");
+ /** Sets the selected avatar on the contact (it does _not_ save it) */
+ public void set_avatar_on_contact () throws GLib.Error {
uint8[] buffer;
this.selected_avatar.save_to_buffer (out buffer, "png", null);
var icon = new BytesIcon (new Bytes (buffer));
- // Set the new avatar
- yield this.individual.change_avatar (icon as LoadableIcon);
+
+ // Save into the most relevant avatar
+ var avatar_chunk = this.contact.get_most_relevant_chunk ("avatar", true);
+ if (avatar_chunk == null)
+ avatar_chunk = this.contact.create_chunk ("avatar", null);
+ ((AvatarChunk) avatar_chunk).avatar = icon;
}
private void update_thumbnail_grid () {
- if (this.individual != null) {
- foreach (var p in individual.personas) {
- var thumbnail = new Thumbnail.for_persona (p);
- if (thumbnail.source_pixbuf != null) {
- this.thumbnail_grid.insert (thumbnail, -1);
- }
+ var filter = new ChunkFilter.for_property ("avatar");
+ var chunks = new Gtk.FilterListModel (this.contact, (owned) filter);
+ for (uint i = 0; i < chunks.get_n_items (); i++) {
+ var chunk = (AvatarChunk) chunks.get_item (i);
+ var thumbnail = new Thumbnail.for_chunk (chunk);
+ if (thumbnail.source_pixbuf != null) {
+ this.thumbnail_grid.insert (thumbnail, -1);
}
}
diff --git a/src/contacts-avatar.vala b/src/contacts-avatar.vala
index bbd9dc7..9f26f11 100644
--- a/src/contacts-avatar.vala
+++ b/src/contacts-avatar.vala
@@ -35,23 +35,43 @@ public class Contacts.Avatar : Adw.Bin {
}
}
- private int avatar_size;
+ private unowned Contact? _contact = null;
+ public Contact? contact {
+ get { return this._contact; }
+ set {
+ if (this._contact == value)
+ return;
+
+ this._contact = value;
+ update_contact ();
+ }
+ }
+
+ public int avatar_size { get; set; default = 48; }
+
+ construct {
+ this.child = new Adw.Avatar (this.avatar_size, "", false);
+ bind_property ("avatar-size", this.child, "size", BindingFlags.DEFAULT);
+ }
public Avatar (int size, Individual? individual = null) {
- this.child = new Adw.Avatar (size, "", false);
- this.avatar_size = size;
+ Object (avatar_size: size, individual: individual);
+ }
- this.individual = individual;
+ public Avatar.for_contact (int size, Contact contact) {
+ Object (avatar_size: size, contact: contact);
}
private void update_individual () {
+ if (this.contact != null)
+ return;
+
string name = "";
bool show_initials = false;
if (this.individual != null) {
name = find_display_name ();
- /* If we don't have a usable name use the display_name
- * to generate the color but don't show any label
- */
+ // If we don't have a usable name use the display_name
+ // to generate the color but don't show any label
if (name == "") {
name = this.individual.display_name;
} else {
@@ -62,18 +82,45 @@ public class Contacts.Avatar : Adw.Bin {
((Adw.Avatar) this.child).show_initials = show_initials;
((Adw.Avatar) this.child).text = name;
- this.load_avatar.begin ();
+ var icon = (this.individual != null)? this.individual.avatar : null;
+ this.load_avatar.begin (icon);
}
- public async void load_avatar () {
- if (this.individual == null || this.individual.avatar == null) {
- set_pixbuf (null);
+ private void update_contact () {
+ if (this.individual != null)
return;
+
+ string name = "";
+ bool show_initials = false;
+ if (this.contact != null) {
+ name = this.contact.fetch_name ();
+ // If we don't have a usable name use the display_name
+ // to generate the color but don't show any label
+ if (name == null)
+ name = this.contact.fetch_display_name ();
+ else
+ show_initials = true;
}
+ ((Adw.Avatar) this.child).show_initials = show_initials;
+ ((Adw.Avatar) this.child).text = name;
+
+ var chunk = this.contact.get_most_relevant_chunk ("avatar", true);
+ if (chunk == null)
+ chunk = this.contact.create_chunk ("avatar", null);
+ unowned var avatar_chunk = (AvatarChunk) chunk;
+ avatar_chunk.notify["avatar"].connect ((obj, pspec) => {
+ this.load_avatar.begin (avatar_chunk.avatar);
+ });
+ this.load_avatar.begin (avatar_chunk.avatar);
+ }
+
+ private async void load_avatar (LoadableIcon? icon) {
+ if (icon == null)
+ return;
+
try {
- var stream = yield this.individual.avatar.load_async (this.avatar_size,
- null);
+ var stream = yield icon.load_async (this.avatar_size, null);
var pixbuf = yield new Gdk.Pixbuf.from_stream_at_scale_async (stream,
this.avatar_size,
this.avatar_size,
diff --git a/src/contacts-chunk-empty-filter.vala b/src/contacts-chunk-empty-filter.vala
new file mode 100644
index 0000000..64009e3
--- /dev/null
+++ b/src/contacts-chunk-empty-filter.vala
@@ -0,0 +1,50 @@
+/*
+ * Copyright (C) 2022 Niels De Graef <nielsdegraef@gmail.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+using Folks;
+
+/**
+ * A custom GtkFilter to filter {@link Chunk}s on them possibly being non-empty.
+ */
+public class Contacts.ChunkEmptyFilter : Gtk.Filter {
+
+ /** Whether empty chunks match the filter */
+ public bool allow_empty {
+ get { return this._allow_empty; }
+ set {
+ if (this._allow_empty == value)
+ return;
+
+ this._allow_empty = value;
+ changed (value? Gtk.FilterChange.LESS_STRICT : Gtk.FilterChange.MORE_STRICT);
+ }
+ }
+ private bool _allow_empty = false;
+
+ public override bool match (GLib.Object? item) {
+ unowned var chunk = (Chunk) item;
+ return match_empty (chunk);
+ }
+
+ private bool match_empty (Chunk chunk) {
+ return this.allow_empty || !chunk.is_empty;
+ }
+
+ public override Gtk.FilterMatch get_strictness () {
+ return this.allow_empty? Gtk.FilterMatch.ALL : Gtk.FilterMatch.SOME;
+ }
+}
diff --git a/src/contacts-chunk-filter.vala b/src/contacts-chunk-filter.vala
new file mode 100644
index 0000000..71c2441
--- /dev/null
+++ b/src/contacts-chunk-filter.vala
@@ -0,0 +1,68 @@
+/*
+ * Copyright (C) 2022 Niels De Graef <nielsdegraef@gmail.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+using Folks;
+
+/**
+ * A custom GtkFilter to filter {@link Chunk}s for a consistent way of
+ * displaying them.
+ */
+public class Contacts.ChunkFilter : Gtk.Filter {
+
+ public ChunkPropertyFilter? property_filter { get; set; default = null; }
+
+ /** Whether empty chunks match the filter */
+ public bool allow_empty {
+ get { return this.empty_filter.allow_empty; }
+ set { this.empty_filter.allow_empty = value; }
+ }
+ private ChunkEmptyFilter empty_filter = new ChunkEmptyFilter ();
+
+ /** A subfilter that can be used to match the persona of each chunk */
+ public PersonaFilter? persona_filter { get; set; default = null; }
+
+ /**
+ * Creates a ChunkFilter for a specific property
+ */
+ public ChunkFilter.for_property (string property_name, bool allow_empty = false) {
+ Object (property_filter: new ChunkPropertyFilter.for_single (property_name),
+ allow_empty: allow_empty);
+ }
+
+ public override bool match (GLib.Object? item) {
+ unowned var chunk = (Chunk) item;
+
+ return match_property_name (chunk)
+ && this.empty_filter.match (chunk)
+ && match_persona (chunk);
+ }
+
+ private bool match_property_name (Chunk chunk) {
+ return this.property_filter == null || this.property_filter.match (chunk);
+ }
+
+ private bool match_persona (Chunk chunk) {
+ if (this.persona_filter == null)
+ return true;
+
+ return chunk.persona == null || this.persona_filter.match (chunk.persona);
+ }
+
+ public override Gtk.FilterMatch get_strictness () {
+ return Gtk.FilterMatch.SOME;
+ }
+}
diff --git a/src/contacts-chunk-property-filter.vala b/src/contacts-chunk-property-filter.vala
new file mode 100644
index 0000000..d2c8233
--- /dev/null
+++ b/src/contacts-chunk-property-filter.vala
@@ -0,0 +1,77 @@
+/*
+ * Copyright (C) 2022 Niels De Graef <nielsdegraef@gmail.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+using Folks;
+
+/**
+ * A custom GtkFilter to filter {@link Chunk}s on a given property.
+ */
+public class Contacts.ChunkPropertyFilter : Gtk.Filter {
+
+ /** If not empty, only the properties in the string list will match */
+ public Gtk.StringList allowed_properties {
+ get { return this._allowed_properties; }
+ construct set {
+ this._allowed_properties = value;
+ value.items_changed.connect ((list, pos, removed, added) => {
+ if ((added > 0 && removed == 0) || list.get_n_items () == 0)
+ changed (Gtk.FilterChange.LESS_STRICT);
+ else if (added == 0 && removed > 0)
+ changed (Gtk.FilterChange.MORE_STRICT);
+ else
+ changed (Gtk.FilterChange.DIFFERENT);
+ });
+ }
+ }
+ private Gtk.StringList _allowed_properties = null;
+
+ /**
+ * Creates a ChunkPropertyFilter for a specific property
+ */
+ public ChunkPropertyFilter (string[] properties) {
+ Object (allowed_properties: new Gtk.StringList (properties));
+ }
+
+ /**
+ * Creates a ChunkPropertyFilter for a specific property
+ */
+ public ChunkPropertyFilter.for_single (string property_name) {
+ Object (allowed_properties: new Gtk.StringList ({ property_name }));
+ }
+
+ public override bool match (GLib.Object? item) {
+ unowned var chunk = (Chunk) item;
+ return match_property_name (chunk);
+ }
+
+ private bool match_property_name (Chunk chunk) {
+ if (this.allowed_properties.get_n_items () == 0)
+ return true;
+
+ for (uint i = 0; i < this.allowed_properties.get_n_items (); i++) {
+ if (chunk.property_name == this.allowed_properties.get_string (i))
+ return true;
+ }
+ return false;
+ }
+
+ public override Gtk.FilterMatch get_strictness () {
+ if (this.allowed_properties.get_n_items () == 0)
+ return Gtk.FilterMatch.ALL;
+ return Gtk.FilterMatch.SOME;
+ }
+}
diff --git a/src/contacts-chunk-sorter.vala b/src/contacts-chunk-sorter.vala
new file mode 100644
index 0000000..e47f440
--- /dev/null
+++ b/src/contacts-chunk-sorter.vala
@@ -0,0 +1,73 @@
+/*
+ * Copyright (C) 2022 Niels De Graef <nielsdegraef@gmail.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+using Folks;
+
+/**
+ * A customer sorter that sorts {@link Chunk}s so that personas are grouped.
+ */
+public class Contacts.ChunkSorter : Gtk.Sorter {
+
+ private PersonaSorter persona_sorter = new PersonaSorter ();
+
+ private const string[] SORTED_PROPERTIES = {
+ "email-addresses",
+ "phone-numbers",
+ "im-addresses",
+ "roles",
+ "urls",
+ "nickname",
+ "birthday",
+ "postal-addresses",
+ "notes"
+ };
+
+ public override Gtk.SorterOrder get_order () {
+ return Gtk.SorterOrder.PARTIAL;
+ }
+
+ public override Gtk.Ordering compare (Object? item1, Object? item2) {
+ unowned var chunk_1 = (Chunk) item1;
+ unowned var chunk_2 = (Chunk) item2;
+
+ // Put null persona's last
+ if ((chunk_1.persona == null) != (chunk_2.persona == null))
+ return (chunk_1.persona == null)? Gtk.Ordering.LARGER : Gtk.Ordering.SMALLER;
+
+ if (chunk_1.persona != null) {
+ var persona_order = this.persona_sorter.compare (chunk_1.persona,
+ chunk_2.persona);
+ if (persona_order != Gtk.Ordering.EQUAL)
+ return persona_order;
+ }
+
+ // We have 2 equal persona's (or 2 times null).
+ // Either way, we can then sort on property name
+ var index_1 = prop_index (chunk_1.property_name);
+ var index_2 = prop_index (chunk_2.property_name);
+ return Gtk.Ordering.from_cmpfunc (index_1 - index_2);
+ }
+
+ private int prop_index (string property_name) {
+ for (int i = 0; i < SORTED_PROPERTIES.length; i++) {
+ if (property_name == SORTED_PROPERTIES[i])
+ return i;
+ }
+
+ return -1;
+ }
+}
diff --git a/src/contacts-contact-editor.vala b/src/contacts-contact-editor.vala
index c68b435..e7f9fc6 100644
--- a/src/contacts-contact-editor.vala
+++ b/src/contacts-contact-editor.vala
@@ -22,35 +22,75 @@ using Folks;
/**
* A widget that allows the user to edit a given {@link Contact}.
*/
-public class Contacts.ContactEditor : Gtk.Box {
+public class Contacts.ContactEditor : Gtk.Widget {
+
+ /** The contact we're editing */
+ public unowned Contact contact { get; construct set; }
+
+ /** The set of distinct personas (or null) that are part of the contact */
+ private GenericArray<Persona?> personas = new GenericArray<Persona?> ();
- private Individual individual;
private unowned Gtk.Entry name_entry;
private unowned Avatar avatar;
construct {
- this.orientation = Gtk.Orientation.VERTICAL;
- this.spacing = 12;
+ var box_layout = new Gtk.BoxLayout (Gtk.Orientation.VERTICAL);
+ box_layout.spacing = 12;
+ set_layout_manager (box_layout);
+
+ add_css_class ("contacts-contact-editor");
+ }
+
+ public ContactEditor (Contact contact) {
+ Object (contact: contact);
+
+ var header = new Gtk.Box (Gtk.Orientation.HORIZONTAL, 6);
+ header.append (create_widget_for_avatar (contact));
+ header.append (create_name_entry (contact));
+ header.set_parent (this);
+
+ contact.items_changed.connect (on_contact_items_changed);
+ on_contact_items_changed (contact, 0, 0, contact.get_n_items ());
+ }
- this.add_css_class ("contacts-contact-editor");
+ public override void dispose () {
+ unowned Gtk.Widget? child = null;
+ while ((child = get_first_child ()) != null)
+ child.unparent ();
+ base.dispose ();
}
- public ContactEditor (Individual individual, IndividualAggregator aggregator) {
- this.individual = individual;
+ private void on_contact_items_changed (GLib.ListModel model,
+ uint position,
+ uint removed,
+ uint added) {
+ for (uint i = position; i < position + added; i++) {
+ var chunk = (Chunk) model.get_item (i);
+
+ // Only add the persona if we can't find it
+ if (this.personas.find (chunk.persona))
+ continue;
- Gtk.Box header = new Gtk.Box (Gtk.Orientation.HORIZONTAL, 6);
- header.append (create_avatar_button ());
- header.append (create_name_entry ());
- append (header);
+ this.personas.add (chunk.persona);
+
+ // Add a header, except for the first persona
+ if (chunk.persona != null && this.personas.length > 1) {
+ var persona_store_header = create_persona_store_label (chunk.persona);
+ persona_store_header.set_parent (this);
+ }
- foreach (var p in individual.personas) {
- append (new EditorPersona (p, aggregator));
+ var persona_editor = new PersonaEditor ((Contact) model, chunk.persona);
+ persona_editor.set_parent (this);
}
+
+ // NOTE: we don't support removing personas here but that should be okay,
+ // since people shouldn't be deleting personas in the first place while
+ // they're still editing
}
// Creates the contact's current avatar in a big button on top of the Editor
- private Gtk.Widget create_avatar_button () {
- var avatar = new Avatar (PROFILE_SIZE, this.individual);
+ private Gtk.Widget create_widget_for_avatar (Contact contact) {
+ var avatar = new Avatar.for_contact (PROFILE_SIZE, contact);
this.avatar = avatar;
var button = new Gtk.Button ();
@@ -63,19 +103,16 @@ public class Contacts.ContactEditor : Gtk.Box {
// Show the avatar popover when the avatar is clicked
private void on_avatar_button_clicked (Gtk.Button avatar_button) {
- var avatar_selector = new AvatarSelector (this.individual, get_root () as Gtk.Window);
+ var avatar_selector = new AvatarSelector (this.contact, get_root () as Gtk.Window);
avatar_selector.response.connect ((response) => {
if (response == Gtk.ResponseType.ACCEPT) {
- avatar_selector.save_selection.begin ((obj, res) => {
- try {
- avatar_selector.save_selection.end (res);
- this.avatar.set_pixbuf (avatar_selector.selected_avatar);
- } catch (Error e) {
- warning ("Failed to set avatar: %s", e.message);
- Utils.show_error_dialog (_("Failed to set avatar."),
- get_root () as Gtk.Window);
- }
- });
+ try {
+ avatar_selector.set_avatar_on_contact ();
+ } catch (Error e) {
+ warning ("Failed to set avatar: %s", e.message);
+ Utils.show_error_dialog (_("Failed to set avatar."),
+ get_root () as Gtk.Window);
+ }
}
avatar_selector.destroy ();
});
@@ -83,8 +120,7 @@ public class Contacts.ContactEditor : Gtk.Box {
}
// Creates the big name entry on the top
- private Gtk.Widget create_name_entry () {
- NameDetails name = this.individual as NameDetails;
+ private Gtk.Widget create_name_entry (Contact contact) {
var entry = new Gtk.Entry ();
this.name_entry = entry;
this.name_entry.hexpand = true;
@@ -92,18 +128,689 @@ public class Contacts.ContactEditor : Gtk.Box {
this.name_entry.input_purpose = Gtk.InputPurpose.NAME;
this.name_entry.placeholder_text = _("Add name");
- // Get primary persona from this.individual
- this.name_entry.text = name.full_name;
+ var fn_chunk = (FullNameChunk?) contact.get_most_relevant_chunk ("full-name", true);
+ if (fn_chunk == null) {
+ warning ("Contact doesn't have a 'full-name' chunk");
+ return null;
+ }
- this.name_entry.changed.connect (() => {
- foreach (var p in this.individual.personas) {
- unowned var name_p = p as NameDetails;
- if (name_p != null) {
- name_p.full_name = this.name_entry.get_text ();
+ fn_chunk.bind_property ("full-name", this.name_entry, "text",
+ BindingFlags.SYNC_CREATE | BindingFlags.BIDIRECTIONAL);
+ return this.name_entry;
+ }
+
+ private Gtk.Label create_persona_store_label (Persona p) {
+ var store_name = new Gtk.Label (Utils.format_persona_store_name_for_contact (p));
+ var attrList = new Pango.AttrList ();
+ attrList.insert (Pango.attr_weight_new (Pango.Weight.BOLD));
+ store_name.set_attributes (attrList);
+ store_name.halign = Gtk.Align.START;
+ store_name.ellipsize = Pango.EllipsizeMode.MIDDLE;
+ return store_name;
+ }
+}
+
+public class Contacts.PersonaEditor : Gtk.Widget {
+
+ /** The contact we're editing a (possibly non-existent) persona of */
+ public unowned Contact contact { get; construct set; }
+
+ /** The specific persona of the contact we're editing */
+ public unowned Persona? persona { get; construct set; }
+
+ // We need to keep a reference to the sorted and filtered list model
+ private ListModel model;
+
+ public const string[] IMPORTANT_PROPERTIES = {
+ "email-addresses",
+ "phone-numbers",
+ };
+
+ public const string[] SUPPORTED_PROPERTIES = {
+ // Note that we don't add full-name and avatar here,
+ // since they're handled separately
+ "birthday",
+ "email-addresses",
+ "nickname",
+ "notes",
+ "phone-numbers",
+ "postal-addresses",
+ "roles",
+ "urls",
+ };
+
+ construct {
+ var box_layout = new Gtk.BoxLayout (Gtk.Orientation.VERTICAL);
+ box_layout.spacing = 6;
+ set_layout_manager (box_layout);
+
+ add_css_class ("contacts-persona-editor");
+
+ ensure_chunks (this.contact);
+
+ var persona_filter = new Gtk.CustomFilter ((item) => {
+ return ((Chunk) item).persona == this.persona;
+ });
+ var persona_model = new Gtk.FilterListModel (this.contact, (owned) persona_filter);
+ return_if_fail (persona_model.get_n_items () > 0);
+
+ // Show all properties that we either ...
+ var filter = new Gtk.AnyFilter ();
+
+ // 1. always want to show
+ var prop_filter = new ChunkPropertyFilter (IMPORTANT_PROPERTIES);
+ filter.append (prop_filter);
+
+ // 2. want to show if they are filled in _and_ supported
+ var non_empty_filter = new Gtk.EveryFilter ();
+ non_empty_filter.append (new ChunkEmptyFilter ());
+ non_empty_filter.append (new ChunkPropertyFilter (SUPPORTED_PROPERTIES));
+ filter.append (non_empty_filter);
+
+ var filtered = new Gtk.FilterListModel (persona_model, filter);
+ this.model = new Gtk.SortListModel (filtered, new ChunkSorter ());
+ model.items_changed.connect (on_model_items_changed);
+ on_model_items_changed (model, 0, 0, model.get_n_items ());
+
+ // Create the "show more" button
+ add_show_more_button (prop_filter);
+ }
+
+ public PersonaEditor (Contact contact, Persona? persona) {
+ Object (contact: contact, persona: persona);
+ }
+
+ public override void dispose () {
+ unowned Gtk.Widget? child = null;
+ while ((child = get_first_child ()) != null)
+ child.unparent ();
+
+ base.dispose ();
+ }
+
+ private void ensure_chunks (Contact contact) {
+ // We can't check what properties will be writable by a persona store
+ // beforehand, so just create an empty chunk for each property we support
+ unowned var writeable_props = SUPPORTED_PROPERTIES;
+ if (persona != null)
+ writeable_props = persona.writeable_properties;
+
+ foreach (unowned var prop in writeable_props) {
+ if (contact.get_most_relevant_chunk (prop, true) == null) {
+ contact.create_chunk (prop, persona);
+ }
+ }
+ }
+
+ // private void add_show_more_button (Gtk.AnyFilter filter) {
+ private void add_show_more_button (ChunkPropertyFilter filter) {
+ var show_more_button = new Gtk.Button ();
+ var show_more_content = new Adw.ButtonContent ();
+ show_more_content.icon_name = "view-more-symbolic";
+ show_more_content.label = _("Show More");
+ show_more_button.set_child (show_more_content);
+ show_more_button.halign = Gtk.Align.CENTER;
+ show_more_button.add_css_class ("flat");
+ show_more_button.clicked.connect ((button) => {
+ button.unparent ();
+ filter.allowed_properties.splice (0,
+ filter.allowed_properties.get_n_items (),
+ SUPPORTED_PROPERTIES);
+ });
+ show_more_button.set_parent (this);
+ }
+
+ private void on_model_items_changed (GLib.ListModel model,
+ uint position,
+ uint removed,
+ uint added) {
+ // Get the widget where we'll have to insert/remove the item at "position"
+ unowned var child = get_first_child ();
+
+ uint current_position = 0;
+ while (current_position < position) {
+ child = child.get_next_sibling ();
+ // If this fails, we somehow have less widgets than items in our model
+ return_if_fail (child != null);
+ current_position++;
+ }
+
+ // First, remove the ones that were removed from the model too
+ while (removed > 0) {
+ unowned var to_remove = child;
+ child = to_remove.get_next_sibling ();
+ to_remove.unparent ();
+ removed--;
+ }
+
+ // Now, add the new ones
+ for (uint i = position; i < position + added; i++) {
+ var chunk = (Chunk) model.get_item (i);
+ var new_child = create_widget_for_chunk (chunk);
+ if (new_child != null)
+ new_child.insert_before (this, child);
+ }
+ }
+
+ private Gtk.Widget? create_widget_for_chunk (Chunk chunk) {
+ switch (chunk.property_name) {
+ case "avatar":
+ case "full-name":
+ return null; // Added separately in the header
+
+ // Please keep these sorted
+ case "birthday":
+ return create_widget_for_birthday (chunk);
+ case "email-addresses":
+ return create_widget_for_emails (chunk);
+ case "nickname":
+ return create_widget_for_nickname (chunk);
+ case "notes":
+ return create_widget_for_notes (chunk);
+ case "phone-numbers":
+ return create_widget_for_phones (chunk);
+ case "postal-addresses":
+ return create_widget_for_addresses (chunk);
+ case "roles":
+ return create_widget_for_roles (chunk);
+ case "urls":
+ return create_widget_for_urls (chunk);
+ default:
+ debug ("Unsupported property: %s", chunk.property_name);
+ return null;
+ }
+ }
+
+ private Gtk.Widget create_widget_for_emails (Chunk chunk)
+ requires (chunk is EmailAddressesChunk) {
+
+ unowned var emails_chunk = (EmailAddressesChunk) chunk;
+ var group = new ContactEditorGroup (contact, persona, emails_chunk, create_email_widget);
+ return group;
+ }
+
+ private Gtk.Widget create_email_widget (BinChunkChild chunk_child) {
+ var row = new Adw.EntryRow ();
+
+ var icon = new Gtk.Image.from_icon_name (chunk_child.icon_name);
+ chunk_child.bind_property ("icon-name", icon, "icon-name", BindingFlags.SYNC_CREATE);
+ row.add_prefix (icon);
+
+ row.title = _("Add email");
+ row.set_input_purpose (Gtk.InputPurpose.EMAIL);
+ chunk_child.bind_property ("raw-address", row, "text",
+ BindingFlags.SYNC_CREATE | BindingFlags.BIDIRECTIONAL);
+
+ var widget = new ContactEditorProperty (row);
+ widget.add_type_combo (chunk_child, TypeSet.email);
+
+ return widget;
+ }
+
+ private Gtk.Widget create_widget_for_phones (Chunk chunk)
+ requires (chunk is PhonesChunk) {
+
+ unowned var phones_chunk = (PhonesChunk) chunk;
+ var group = new ContactEditorGroup (contact, persona, phones_chunk, create_phone_widget);
+ return group;
+ }
+
+ private Gtk.Widget create_phone_widget (BinChunkChild chunk_child) {
+ var row = new Adw.EntryRow ();
+
+ var icon = new Gtk.Image.from_icon_name (chunk_child.icon_name);
+ chunk_child.bind_property ("icon-name", icon, "icon-name", BindingFlags.SYNC_CREATE);
+ row.add_prefix (icon);
+
+ row.title = _("Add phone number");
+ row.set_input_purpose (Gtk.InputPurpose.PHONE);
+ chunk_child.bind_property ("raw-number", row, "text",
+ BindingFlags.SYNC_CREATE | BindingFlags.BIDIRECTIONAL);
+
+ var widget = new ContactEditorProperty (row);
+ widget.add_type_combo (chunk_child, TypeSet.phone);
+
+ return widget;
+ }
+
+ private Gtk.Widget create_widget_for_urls (Chunk chunk)
+ requires (chunk is UrlsChunk) {
+
+ unowned var urls_chunk = (UrlsChunk) chunk;
+ var group = new ContactEditorGroup (contact, persona, urls_chunk, create_url_widget);
+ return group;
+ }
+
+ private Gtk.Widget create_url_widget (BinChunkChild chunk_child) {
+ var row = new Adw.EntryRow ();
+
+ var icon = new Gtk.Image.from_icon_name (chunk_child.icon_name);
+ chunk_child.bind_property ("icon-name", icon, "icon-name", BindingFlags.SYNC_CREATE);
+ row.add_prefix (icon);
+
+ row.title = _("Website");
+ row.set_input_purpose (Gtk.InputPurpose.URL);
+ chunk_child.bind_property ("raw-url", row, "text",
+ BindingFlags.SYNC_CREATE | BindingFlags.BIDIRECTIONAL);
+
+ return new ContactEditorProperty (row);
+ }
+
+ private Gtk.Widget create_widget_for_nickname (Chunk chunk)
+ requires (chunk is NicknameChunk) {
+ var row = new Adw.EntryRow ();
+ row.add_prefix (new Gtk.Image.from_icon_name ("avatar-default-symbolic"));
+ row.title = _("Nickname");
+ row.set_input_purpose (Gtk.InputPurpose.NAME);
+ chunk.bind_property ("nickname", row, "text", BindingFlags.SYNC_CREATE | BindingFlags.BIDIRECTIONAL);
+
+ return new ContactEditorProperty (row);
+ }
+
+ private Gtk.Widget create_widget_for_notes (Chunk chunk)
+ requires (chunk is NotesChunk) {
+ unowned var notes_chunk = (NotesChunk) chunk;
+ var group = new ContactEditorGroup (contact, persona, notes_chunk, create_note_widget);
+ return group;
+ }
+
+ private Gtk.Widget create_note_widget (BinChunkChild chunk_child) {
+ //XXX create a subclass NoteEditor instead
+ var row = new Adw.PreferencesRow ();
+
+ var header = new Gtk.Box (Gtk.Orientation.HORIZONTAL, 0);
+ header.add_css_class ("header");
+ row.set_child (header);
+
+ var prefixes = new Gtk.Box (Gtk.Orientation.HORIZONTAL, 0);
+ prefixes.add_css_class ("prefixes");
+ var icon = new Gtk.Image.from_icon_name (chunk_child.icon_name);
+ chunk_child.bind_property ("icon-name", icon, "icon-name", BindingFlags.SYNC_CREATE);
+ prefixes.append (icon);
+ header.append (prefixes);
+
+ var sw = new Gtk.ScrolledWindow ();
+ sw.focusable = false;
+ sw.has_frame = false;
+ sw.set_size_request (-1, 100);
+
+ var textview = new Gtk.TextView ();
+ chunk_child.bind_property ("text", textview.buffer, "text",
+ BindingFlags.SYNC_CREATE | BindingFlags.BIDIRECTIONAL);
+ textview.hexpand = true;
+ sw.set_child (textview);
+
+ header.append (sw);
+
+ return new ContactEditorProperty (row);
+ }
+
+ private Gtk.Widget create_widget_for_birthday (Chunk chunk)
+ requires (chunk is BirthdayChunk) {
+ var bd_chunk = (BirthdayChunk) chunk;
+
+ var row = new Adw.ActionRow ();
+ row.add_prefix (new Gtk.Image.from_icon_name ("birthday-symbolic"));
+ row.title = _("Birthday");
+
+ Gtk.Button button;
+ if (bd_chunk.birthday == null) {
+ button = new Gtk.Button.with_label (_("Set Birthday"));
+ } else {
+ button = new Gtk.Button.with_label (bd_chunk.birthday.to_local ().format ("%x"));
+ }
+ button.valign = Gtk.Align.CENTER;
+ button.clicked.connect (() => {
+ unowned var parent_window = get_root () as Gtk.Window;
+ var dialog = new BirthdayEditor (parent_window, bd_chunk.birthday);
+ dialog.changed.connect (() => {
+ if (dialog.is_set) {
+ bd_chunk.birthday = dialog.get_birthday ();
+ button.set_label (bd_chunk.birthday.to_local ().format ("%x"));
}
+ });
+ dialog.present ();
+ });
+ row.add_suffix (button);
+ row.set_activatable_widget (button);
+
+ return new ContactEditorProperty (row);
+ }
+
+ private Gtk.Widget create_widget_for_addresses (Chunk chunk)
+ requires (chunk is AddressesChunk) {
+ unowned var addresses_chunk = (AddressesChunk) chunk;
+ var group = new ContactEditorGroup (contact, persona, addresses_chunk, create_address_widget);
+ return group;
+ }
+
+ private Gtk.Widget create_address_widget (BinChunkChild chunk_child) {
+ unowned var address_chunk = (Address) chunk_child;
+ //XXX create a subclass AddressEditor instead
+ var row = new Adw.PreferencesRow ();
+
+ var header = new Gtk.Box (Gtk.Orientation.HORIZONTAL, 0);
+ header.add_css_class ("header");
+ row.set_child (header);
+
+ var prefixes = new Gtk.Box (Gtk.Orientation.HORIZONTAL, 0);
+ prefixes.add_css_class ("prefixes");
+ var icon = new Gtk.Image.from_icon_name (chunk_child.icon_name);
+ chunk_child.bind_property ("icon-name", icon, "icon-name", BindingFlags.SYNC_CREATE);
+ prefixes.append (icon);
+ header.append (prefixes);
+
+ var editor = new AddressEditor (address_chunk);
+ editor.hexpand = true;
+ header.append (editor);
+
+ var widget = new ContactEditorProperty (row);
+ widget.add_type_combo (chunk_child, TypeSet.general);
+ return widget;
+ }
+
+ private Gtk.Widget create_widget_for_roles (Chunk chunk)
+ requires (chunk is RolesChunk) {
+
+ unowned var roles_chunk = (RolesChunk) chunk;
+ var group = new ContactEditorGroup (contact, persona, roles_chunk, create_role_widget);
+ return group;
+ }
+
+ private Gtk.Widget create_role_widget (BinChunkChild chunk_child) {
+ unowned var role_chunk = (OrgRole) chunk_child;
+
+ // 2 rows: one for the role, one for the org
+ var org_row = new Adw.EntryRow ();
+ var icon = new Gtk.Image.from_icon_name (chunk_child.icon_name);
+ chunk_child.bind_property ("icon-name", icon, "icon-name", BindingFlags.SYNC_CREATE);
+ org_row.add_prefix (icon);
+ org_row.title = _("Organisation");
+ role_chunk.role.bind_property ("organisation-name", org_row, "text",
+ BindingFlags.BIDIRECTIONAL | BindingFlags.SYNC_CREATE);
+ var widget = new ContactEditorProperty (org_row);
+
+ var role_row = new Adw.EntryRow ();
+ role_row.title = _("Role");
+ role_chunk.role.bind_property ("title", role_row, "text",
+ BindingFlags.SYNC_CREATE | BindingFlags.BIDIRECTIONAL);
+ widget.add (role_row);
+
+ return widget;
+ }
+}
+
+/** A widget for {@link BinChunk}s, allowing to create a widget for each */
+public class Contacts.ContactEditorGroup : Gtk.Widget {
+
+ public unowned Contact contact { get; construct set; }
+
+ public unowned Persona? persona { get; construct set; }
+
+ public delegate Gtk.Widget CreateWidgetFunc (BinChunkChild chunk_child);
+
+ private unowned CreateWidgetFunc create_widget_func;
+
+ construct {
+ var box_layout = new Gtk.BoxLayout (Gtk.Orientation.VERTICAL);
+ box_layout.spacing = 6;
+ set_layout_manager (box_layout);
+
+ add_css_class ("contact-editor-group");
+ }
+
+ public ContactEditorGroup (Contact contact, Persona? persona, BinChunk chunk, CreateWidgetFunc func) {
+ Object (contact: contact, persona: persona);
+
+ this.create_widget_func = func;
+
+ chunk.items_changed.connect (on_bin_chunk_items_changed);
+ on_bin_chunk_items_changed (chunk, 0, 0, chunk.get_n_items ());
+ }
+
+ public override void dispose () {
+ unowned Gtk.Widget? child = null;
+ while ((child = get_first_child ()) != null)
+ child.unparent ();
+
+ base.dispose ();
+ }
+
+ private void on_bin_chunk_items_changed (GLib.ListModel model,
+ uint position,
+ uint removed,
+ uint added) {
+ // Get the widget where we'll have to insert/remove the item at "position"
+ unowned var child = get_first_child ();
+
+ uint current_position = 0;
+ while (current_position < position) {
+ child = child.get_next_sibling ();
+ current_position++;
+ }
+
+ // First, remove the ones that were removed from the model too
+ while (removed > 0) {
+ unowned var to_remove = child;
+ child = to_remove.get_next_sibling ();
+ to_remove.unparent ();
+ removed--;
+ }
+
+ // Now, add the new ones
+ for (uint i = position; i < position + added; i++) {
+ var chunk_child = (BinChunkChild) model.get_item (i);
+ var new_child = this.create_widget_func (chunk_child);
+ if (new_child != null)
+ new_child.insert_before (this, child);
+ }
+ }
+}
+
+/**
+ * Widget wrapper to show a single property of a contact (for example an email
+ * address, a birthday, ...). It can show itself using a GtkRevealer animation.
+ */
+public class Contacts.ContactEditorProperty : Gtk.Widget {
+
+ private unowned Adw.PreferencesGroup group;
+
+ static construct {
+ set_layout_manager_type (typeof (Gtk.BinLayout));
+ }
+
+ public ContactEditorProperty (Gtk.Widget widget) {
+ var revealer = new Gtk.Revealer ();
+ revealer.set_parent (this);
+
+ var prefs_group = new Adw.PreferencesGroup ();
+ prefs_group.add_css_class ("contacts-editor-property");
+ this.group = prefs_group;
+ revealer.set_child (prefs_group);
+ // By default, reveal the child
+ revealer.reveal_child = true;
+
+ group.add (widget);
+ }
+
+ public override void dispose () {
+ get_first_child ().unparent ();
+ base.dispose ();
+ }
+
+ public void add_type_combo (BinChunkChild chunk_child,
+ TypeSet combo_type) {
+ var row = new TypeComboRow (combo_type);
+ row.title = _("Label");
+ row.set_selected_from_parameters (chunk_child.parameters);
+ add (row);
+
+ row.notify["selected-item"].connect ((obj, pspec) => {
+ unowned var descr = row.selected_descriptor;
+ chunk_child.parameters = descr.adapt_parameters (chunk_child.parameters);
+ });
+ }
+
+ public void add (Gtk.Widget widget) {
+ this.group.add (widget);
+ }
+}
+
+public class Contacts.BirthdayEditor : Gtk.Dialog {
+
+ private unowned Gtk.SpinButton day_spin;
+ private unowned Gtk.ComboBoxText month_combo;
+ private unowned Gtk.SpinButton year_spin;
+
+ public bool is_set { get; set; default = false; }
+
+ public signal void changed ();
+
+ construct {
+ // The grid that will contain the Y/M/D fields
+ var grid = new Gtk.Grid ();
+ grid.column_spacing = 12;
+ grid.row_spacing = 12;
+ grid.add_css_class ("contacts-editor-birthday");
+ ((Gtk.Box) this.get_content_area ()).append (grid);
+
+ // Day
+ var d_spin = new Gtk.SpinButton.with_range (1.0, 31.0, 1.0);
+ d_spin.digits = 0;
+ d_spin.numeric = true;
+ this.day_spin = d_spin;
+
+ // Month
+ var m_combo = new Gtk.ComboBoxText ();
+ var january = new DateTime.local (1, 1, 1, 1, 1, 1);
+ for (int i = 0; i < 12; i++) {
+ var month = january.add_months (i);
+ m_combo.append_text (month.format ("%B"));
+ }
+ m_combo.hexpand = true;
+ this.month_combo = m_combo;
+
+ // Year
+ var y_spin = new Gtk.SpinButton.with_range (1800, 3000, 1);
+ y_spin.set_digits (0);
+ y_spin.numeric = true;
+ this.year_spin = y_spin;
+
+ // Create grid and labels
+ Gtk.Label day = new Gtk.Label (_("Day"));
+ day.set_halign (Gtk.Align.END);
+ grid.attach (day, 0, 0);
+ grid.attach (day_spin, 1, 0);
+ Gtk.Label month = new Gtk.Label (_("Month"));
+ month.set_halign (Gtk.Align.END);
+ grid.attach (month, 0, 1);
+ grid.attach (month_combo, 1, 1);
+ Gtk.Label year = new Gtk.Label (_("Year"));
+ year.set_halign (Gtk.Align.END);
+ grid.attach (year, 0, 2);
+ grid.attach (year_spin, 1, 2);
+
+ this.title = _("Change Birthday");
+ add_buttons (_("Set"), Gtk.ResponseType.OK,
+ _("Cancel"), Gtk.ResponseType.CANCEL,
+ null);
+ var ok_button = this.get_widget_for_response (Gtk.ResponseType.OK);
+ ok_button.add_css_class ("suggested-action");
+ this.response.connect ((id) => {
+ switch (id) {
+ case Gtk.ResponseType.OK:
+ this.is_set = true;
+ changed ();
+ break;
+ case Gtk.ResponseType.CANCEL:
+ break;
}
+ this.destroy ();
});
+ }
- return this.name_entry;
+ public BirthdayEditor (Gtk.Window window, DateTime? birthday) {
+ Object (transient_for: window, use_header_bar: 1, modal: true);
+
+ // Don't forget to change to local timezone first
+ var bday_local = (birthday != null)? birthday.to_local () : new DateTime.now_local ();
+ this.day_spin.set_value ((double) bday_local.get_day_of_month ());
+ this.month_combo.set_active (bday_local.get_month () - 1);
+ this.year_spin.set_value ((double) bday_local.get_year ());
+
+ update_date ();
+ month_combo.changed.connect (() => {
+ update_date ();
+ });
+ year_spin.value_changed.connect (() => {
+ update_date ();
+ });
+ }
+
+ /** Returns the selected birthday (in UTC timezone) */
+ public GLib.DateTime get_birthday () {
+ return new GLib.DateTime.local (year_spin.get_value_as_int (),
+ month_combo.get_active () + 1,
+ day_spin.get_value_as_int (),
+ 0, 0, 0).to_utc ();
+ }
+
+ private void update_date() {
+ const int[] month_of_31 = {3, 5, 8, 10};
+
+ if (this.month_combo.get_active () in month_of_31) {
+ this.day_spin.set_range (1, 30);
+ } else if (this.month_combo.get_active () == 1) {
+ if (this.year_spin.get_value_as_int () % 400 == 0 ||
+ (this.year_spin.get_value_as_int () % 4 == 0 &&
+ this.year_spin.get_value_as_int () % 100 != 0)) {
+ this.day_spin.set_range (1, 29);
+ } else {
+ this.day_spin.set_range (1, 28);
+ }
+ } else {
+ this.day_spin.set_range (1, 31);
+ }
+ }
+}
+
+public class Contacts.AddressEditor : Gtk.Widget {
+
+ private const string[] postal_element_props = {
+ "street", "extension", "locality", "region", "postal_code", "po_box", "country"
+ };
+ private static string[] postal_element_names = {
+ _("Street"), _("Extension"), _("City"), _("State/Province"), _("Zip/Postal Code"), _("PO box"), _("Country")
+ };
+
+ public signal void changed ();
+
+ construct {
+ var box_layout = new Gtk.BoxLayout (Gtk.Orientation.VERTICAL);
+ set_layout_manager (box_layout);
+
+ add_css_class ("contacts-editor-address");
+ }
+
+ public AddressEditor (Address address) {
+ for (int i = 0; i < postal_element_props.length; i++) {
+ var entry = new Gtk.Entry ();
+ entry.hexpand = true;
+ entry.placeholder_text = AddressEditor.postal_element_names[i];
+ entry.add_css_class ("flat");
+
+ unowned var prop_name = AddressEditor.postal_element_props[i];
+ address.address.bind_property (prop_name, entry, "text",
+ BindingFlags.SYNC_CREATE | BindingFlags.BIDIRECTIONAL);
+
+ entry.set_parent (this);
+ }
+ }
+
+ public override void dispose () {
+ unowned Gtk.Widget? child = null;
+ while ((child = get_first_child ()) != null)
+ child.unparent ();
+ base.dispose ();
}
}
diff --git a/src/contacts-contact-pane.vala b/src/contacts-contact-pane.vala
index b375a9c..2a16e22 100644
--- a/src/contacts-contact-pane.vala
+++ b/src/contacts-contact-pane.vala
@@ -30,7 +30,7 @@ public class Contacts.ContactPane : Adw.Bin {
private unowned Store store;
- private Individual? individual = null;
+ private Contact? contact = null;
[GtkChild]
private unowned Gtk.Stack stack;
@@ -52,18 +52,18 @@ public class Contacts.ContactPane : Adw.Bin {
this.store = contacts_store;
}
- public void add_suggestion (Individual i) {
+ public void add_suggestion (Individual individual, Individual other) {
unowned var parent_overlay = this.get_parent () as Gtk.Overlay;
remove_suggestion_grid ();
- this.suggestion_grid = new LinkSuggestionGrid (i);
+ this.suggestion_grid = new LinkSuggestionGrid (other);
this.suggestion_grid.valign = Gtk.Align.END;
parent_overlay.add_overlay (this.suggestion_grid);
this.suggestion_grid.suggestion_accepted.connect (() => {
var to_link = new Gee.LinkedList<Individual> ();
- to_link.add (this.individual);
- to_link.add (i);
+ to_link.add (individual);
+ to_link.add (other);
var operation = new LinkOperation (this.store, to_link);
this.contacts_linked (operation);
remove_suggestion_grid ();
@@ -71,40 +71,42 @@ public class Contacts.ContactPane : Adw.Bin {
this.suggestion_grid.suggestion_rejected.connect (() => {
/* TODO: Add undo */
- store.add_no_suggest_link (this.individual, i);
+ store.add_no_suggest_link (individual, other);
remove_suggestion_grid ();
});
}
public void show_contact (Individual? individual) {
- if (this.individual == individual)
- return;
-
- this.individual = individual;
-
- if (this.individual != null) {
- show_contact_sheet ();
- } else {
+ if (individual == null) {
+ this.contact = null;
remove_contact_sheet ();
this.stack.set_visible_child_name ("none-selected-page");
+ return;
}
+
+ if (this.contact == null || this.contact.individual != individual)
+ this.contact = new Contact.for_individual (individual, this.store);
+ show_contact_sheet (this.contact);
}
- private void show_contact_sheet () {
- return_if_fail (this.individual != null);
+ private void show_contact_sheet (Contact contact) {
+ return_if_fail (contact != null);
remove_contact_sheet ();
- var contacts_sheet = new ContactSheet (this.individual, this.store);
+ var contacts_sheet = new ContactSheet (contact);
contacts_sheet.hexpand = true;
this.sheet = contacts_sheet;
this.contact_sheet_clamp.set_child (this.sheet);
this.stack.set_visible_child_name ("contact-sheet-page");
- var matches = this.store.aggregator.get_potential_matches (this.individual, MatchResult.HIGH);
- foreach (var i in matches.keys) {
- if (i != null && Contacts.Utils.suggest_link_to (this.store, this.individual, i)) {
- add_suggestion (i);
- break;
+ // Show potential link suggestions only if it's an existing contact
+ if (contact.individual != null) {
+ var matches = this.store.aggregator.get_potential_matches (contact.individual, MatchResult.HIGH);
+ foreach (var i in matches.keys) {
+ if (i != null && Utils.suggest_link_to (this.store, contact.individual, i)) {
+ add_suggestion (contact.individual, i);
+ break;
+ }
}
}
}
@@ -121,9 +123,11 @@ public class Contacts.ContactPane : Adw.Bin {
}
private void create_contact_editor () {
- remove_contact_editor ();
+ return_if_fail (this.contact != null);
- var contact_editor = new ContactEditor (this.individual, store.aggregator);
+ remove_contact_editor ();
+ var contact_editor = new ContactEditor (this.contact);
+ contact_editor.hexpand = true;
this.editor = contact_editor;
this.contact_editor_box.append (this.editor);
@@ -137,43 +141,28 @@ public class Contacts.ContactPane : Adw.Bin {
this.editor = null;
}
- private void start_editing() {
- if (this.on_edit_mode || this.individual == null)
- return;
-
- this.on_edit_mode = true;
-
- create_contact_editor ();
- this.stack.set_visible_child_name ("contact-editor-page");
- }
-
public void stop_editing (bool cancel = false) {
- if (!this.on_edit_mode)
- return;
+ return_if_fail (this.on_edit_mode);
this.on_edit_mode = false;
remove_contact_editor ();
if (cancel) {
- var fake_individual = individual as FakeIndividual;
- if (fake_individual != null && fake_individual.real_individual != null) {
- // Reset individual on to the real one
- this.individual = fake_individual.real_individual;
+ if (this.contact != null) {
this.stack.set_visible_child_name ("contact-sheet-page");
} else {
this.stack.set_visible_child_name ("none-selected-page");
}
- return;
+ } else {
+ // Save changes if editing wasn't canceled
+ apply_changes.begin (this.contact);
}
-
- /* Save changes if editing wasn't canceled */
- apply_changes.begin ();
}
- private async void apply_changes () {
- /* Show fake contact to the user */
- /* TODO: block changes to fake contact */
- show_contact_sheet ();
+ private async void apply_changes (Contact contact) {
+ // TODO: block changes to contact
+ show_contact_sheet (contact);
+
// Wait that the store gets quiescent if it isn't already
if (!this.store.aggregator.is_quiescent) {
ulong signal_id;
@@ -184,83 +173,36 @@ public class Contacts.ContactPane : Adw.Bin {
yield;
disconnect (signal_id);
}
- var fake_individual = individual as FakeIndividual;
- if (fake_individual != null && fake_individual.real_individual == null) {
- // Create a new persona in the primary store based on the fake persona
- yield create_contact (fake_individual.primary_persona);
- } else {
- yield fake_individual.apply_changes_to_real ();
- /* Todo: we need to check if the changes where applied to the contact */
- this.individual = fake_individual.real_individual;
- }
- /* Replace fake contact with real contact */
- show_contact_sheet ();
+ try {
+ yield contact.apply_changes ();
+ } catch (Error err) {
+ warning ("Couldn't save changes: %s", err.message);
+ // XXX do something better here
+ }
+ show_contact_sheet (contact);
}
public void edit_contact () {
- this.individual = new FakeIndividual.from_real (this.individual);
- start_editing ();
- }
-
- public void new_contact () {
- var details = new HashTable<string, Value?> (str_hash, str_equal);
- string[] writeable_properties;
- // TODO: make sure we have a primary_store
- if (this.store.aggregator.primary_store != null) {
- // FIXME: We shouldn't use this list but there isn't an other way to find writeable_properties, and we should expect that all properties are writeable
- writeable_properties = this.store.aggregator.primary_store.always_writeable_properties;
- } else {
- writeable_properties = {};
- }
+ return_if_fail (this.contact != null);
+ if (this.on_edit_mode)
+ return;
- var fake_persona = new FakePersona (FakePersonaStore.the_store (), writeable_properties, details);
- var fake_personas = new Gee.HashSet<FakePersona> ();
- fake_personas.add (fake_persona);
- this.individual = new FakeIndividual (fake_personas);
+ this.on_edit_mode = true;
- start_editing ();
+ create_contact_editor ();
+ this.stack.set_visible_child_name ("contact-editor-page");
}
- // Create a new contact from the FakePersona
- public async void create_contact (FakePersona fake_persona) {
- var details = fake_persona.get_details ();
-
- if (this.store.aggregator.primary_store == null) {
- show_message_dialog (_("No primary addressbook configured"));
- return;
- }
-
- // Create the contact
- var primary_store = this.store.aggregator.primary_store;
- Persona? persona = null;
- try {
- persona = yield primary_store.add_persona_from_details (details);
- } catch (Error e) {
- show_message_dialog (_("Unable to create new contacts: %s").printf (e.message));
- this.store.selection.unselect_item (this.store.selection.get_selected ());
+ public void new_contact () {
+ this.contact = new Contact.for_new (this.store);
+ if (this.on_edit_mode)
return;
- }
-
- // Now show the real persona to the user (if we can find it)
- for (uint i = 0; i < this.store.filter_model.get_n_items (); i++) {
- if (persona.individual == this.store.filter_model.get_item (i)) {
- // FIXME: This causes a flicker, especially visible when an avatar is set
- this.store.selection.selected = i;
- return;
- }
- }
- // If we got here, we couldn't find the individual
- show_message_dialog (_("Unable to find newly created contact"));
- this.store.selection.unselect_item (this.store.selection.get_selected ());
- }
+ this.on_edit_mode = true;
- private void show_message_dialog (string message) {
- var dialog = new Adw.MessageDialog (this.get_root () as Gtk.Window, null, message);
- dialog.add_response ("close", _("_Close"));
- dialog.default_response = "close";
- dialog.show ();
+ create_contact_editor ();
+ this.stack.set_visible_child_name ("contact-editor-page");
}
private void remove_suggestion_grid () {
diff --git a/src/contacts-contact-sheet.vala b/src/contacts-contact-sheet.vala
index 81b3293..19f1201 100644
--- a/src/contacts-contact-sheet.vala
+++ b/src/contacts-contact-sheet.vala
@@ -17,158 +17,152 @@
using Folks;
-public class Contacts.ContactSheetRow : Adw.ActionRow {
-
- construct {
- this.title_selectable = true;
- }
-
- public ContactSheetRow (string property_name, string title, string? subtitle = null) {
- unowned var icon_name = Utils.get_icon_name_for_property (property_name);
- if (icon_name != null) {
- var icon = new Gtk.Image.from_icon_name (icon_name);
- icon.add_css_class ("contacts-property-icon");
- icon.tooltip_text = Utils.get_display_name_for_property (property_name);
- this.add_prefix (icon);
- }
-
- this.title = Markup.escape_text (title);
-
- if (subtitle != null)
- this.subtitle = subtitle;
- }
-
- public Gtk.Button add_button (string icon) {
- var button = new Gtk.Button.from_icon_name (icon);
- button.valign = Gtk.Align.CENTER;
- button.add_css_class ("flat");
- this.add_suffix (button);
- return button;
- }
-}
-
/**
* The contact sheet displays the actual information of a contact.
*
* (Note: to edit a contact, use the {@link ContactEditor} instead.
*/
-public class Contacts.ContactSheet : Gtk.Grid {
-
- private int last_row = 0;
- private unowned Individual individual;
- private unowned Store store;
-
- private const string[] SORTED_PROPERTIES = {
- "email-addresses",
- "phone-numbers",
- "im-addresses",
- "roles",
- "urls",
- "nickname",
- "birthday",
- "postal-addresses",
- "notes"
- };
+public class Contacts.ContactSheet : Gtk.Widget {
construct {
+ var box_layout = new Gtk.BoxLayout (Gtk.Orientation.VERTICAL);
+ set_layout_manager (box_layout);
+
this.add_css_class ("contacts-sheet");
}
- public ContactSheet (Individual individual, Store store) {
- this.individual = individual;
- this.store = store;
+ public ContactSheet (Contact contact) {
+ // Apply some filtering/sorting to the base model
+ var filter = new ChunkFilter ();
+ filter.persona_filter = new PersonaFilter ();
+ var filtered = new Gtk.FilterListModel (contact, filter);
+ var contact_model = new Gtk.SortListModel (filtered, new ChunkSorter ());
- this.individual.notify.connect (update);
- this.individual.personas_changed.connect (update);
- store.quiescent.connect (update);
+ var header = create_header (contact);
+ header.set_parent (this);
- update ();
+ contact_model.items_changed.connect (on_model_items_changed);
+ on_model_items_changed (contact_model, 0, 0, contact_model.get_n_items ());
}
- private Gtk.Label create_persona_store_label (Persona p) {
- var store_name = new Gtk.Label (Utils.format_persona_store_name_for_contact (p));
- var attrList = new Pango.AttrList ();
- attrList.insert (Pango.attr_weight_new (Pango.Weight.BOLD));
- store_name.set_attributes (attrList);
- store_name.halign = Gtk.Align.START;
- store_name.ellipsize = Pango.EllipsizeMode.MIDDLE;
+ public override void dispose () {
+ unowned Gtk.Widget? child = null;
+ while ((child = get_first_child ()) != null)
+ child.unparent ();
- return store_name;
+ base.dispose ();
}
- // Helper function that attaches a set of property rows to our grid
- private void attach_rows (GLib.List<Gtk.ListBoxRow>? rows) {
- if (rows == null)
- return;
-
- var group = new Adw.PreferencesGroup ();
- group.add_css_class ("contacts-sheet-property");
-
- foreach (unowned var row in rows)
- group.add (row);
-
- this.attach (group, 0, this.last_row, 3, 1);
- this.last_row++;
- }
+ private void on_model_items_changed (GLib.ListModel model,
+ uint position,
+ uint removed,
+ uint added) {
+ // Get the widget where we'll have to append the item at "position". Note
+ // that we need to take care of the header and the persona store titles
+ unowned var child = get_first_child ();
+ return_if_fail (child != null); // Header is always available
- private void attach_row (Gtk.ListBoxRow row) {
- var rows = new GLib.List<Gtk.ListBoxRow> ();
- rows.prepend (row);
- this.attach_rows (rows);
- }
+ uint current_position = 0;
+ while (current_position < position) {
+ child = child.get_next_sibling ();
+ // If this fails, we somehow have less widgets than items in our model
+ return_if_fail (child != null);
- private void update () {
- this.last_row = 0;
+ // Ignore persona store labels
+ if (child is Gtk.Label)
+ continue;
- // Remove all fields
- unowned var child = get_first_child ();
- while (child != null) {
- unowned var next = child.get_next_sibling ();
- remove (child);
- child = next;
+ current_position++;
}
- var header = create_header ();
- this.attach (header, 0, 0, 1, 1);
-
- this.last_row++;
-
- var personas = Utils.personas_as_list_model (individual);
- var personas_filtered = new Gtk.FilterListModel (personas, new PersonaFilter ());
- var personas_sorted = new Gtk.SortListModel (personas_filtered, new PersonaSorter ());
+ // First, remove the ones that were removed from the model too
+ while (removed != 0) {
+ unowned var to_remove = child.get_next_sibling ();
+ return_if_fail (to_remove != null); // if this happens we're out of sync
+ to_remove.unparent ();
+ removed--;
+ }
+ // It could be that we now ended up with a empty persona store label
+ if (child is Gtk.Label) {
+ child = child.get_prev_sibling ();
+ child.get_next_sibling ().unparent ();
+ }
- for (int i = 0; i < personas_sorted.get_n_items (); i++) {
- var persona = (Persona) personas_sorted.get_item (i);
- int persona_store_pos = this.last_row;
+ // Now, add the new ones
+ for (uint i = position; i < position + added; i++) {
+ var chunk = (Chunk) model.get_item (i);
+
+ // Check if we need to add a persona store label
+ if (i > 0 && chunk.persona != null && !(child is Gtk.Label)) {
+ var prev = (Chunk?) model.get_item (i - 1);
+ if (prev.persona != chunk.persona) {
+ var label = create_persona_store_label (chunk.persona);
+ label.insert_after (this, child);
+ child = label;
+ }
+ }
- if (i > 0) {
- this.attach (create_persona_store_label (persona), 0, this.last_row, 3);
- this.last_row++;
+ var new_child = create_widget_for_chunk (chunk);
+ if (new_child != null) {
+ new_child.insert_after (this, child);
+ child = new_child;
}
+ }
+ }
- foreach (unowned var prop in SORTED_PROPERTIES)
- add_row_for_property (persona, prop);
+ private Gtk.Widget? create_widget_for_chunk (Chunk chunk) {
+ switch (chunk.property_name) {
+ case "avatar":
+ case "full-name":
+ return null; // Added separately in the header
- // Nothing to show in the persona: don't mention it
- bool is_empty_persona = (this.last_row == persona_store_pos + 1);
- if (i > 0 && is_empty_persona) {
- this.remove_row (persona_store_pos);
- this.last_row--;
- }
+ // Please keep these sorted
+ case "birthday":
+ return create_widget_for_birthday (chunk);
+ case "email-addresses":
+ return create_widget_for_emails (chunk);
+ case "im-addresses":
+ return create_widget_for_im_addresses (chunk);
+ case "nickname":
+ return create_widget_for_nickname (chunk);
+ case "notes":
+ return create_widget_for_notes (chunk);
+ case "phone-numbers":
+ return create_widget_for_phone_nrs (chunk);
+ case "postal-addresses":
+ return create_widget_for_postal_addresses (chunk);
+ case "roles":
+ return create_widget_for_roles (chunk);
+ case "urls":
+ return create_widget_for_urls (chunk);
+ default:
+ debug ("Unsupported property: %s", chunk.property_name);
+ return null;
}
}
- private Gtk.Widget create_header () {
+ private Gtk.Label create_persona_store_label (Persona p) {
+ var store_name = new Gtk.Label (Utils.format_persona_store_name_for_contact (p));
+ var attrList = new Pango.AttrList ();
+ attrList.insert (Pango.attr_weight_new (Pango.Weight.BOLD));
+ store_name.set_attributes (attrList);
+ store_name.halign = Gtk.Align.START;
+ store_name.ellipsize = Pango.EllipsizeMode.MIDDLE;
+
+ return store_name;
+ }
+
+ private Gtk.Widget create_header (Contact contact) {
var header = new Gtk.Box (Gtk.Orientation.HORIZONTAL, 18);
header.add_css_class ("contacts-sheet-header");
- var image_frame = new Avatar (PROFILE_SIZE, this.individual);
+ var image_frame = new Avatar.for_contact (PROFILE_SIZE, contact);
image_frame.vexpand = false;
image_frame.valign = Gtk.Align.START;
header.append (image_frame);
var name_label = new Gtk.Label ("");
- name_label.label = this.individual.display_name;
+ name_label.label = contact.display_name;
name_label.hexpand = true;
name_label.xalign = 0f;
name_label.wrap = true;
@@ -183,285 +177,251 @@ public class Contacts.ContactSheet : Gtk.Grid {
return header;
}
- private void add_row_for_property (Persona persona, string property) {
- switch (property) {
- case "email-addresses":
- add_emails (persona, property);
- break;
- case "phone-numbers":
- add_phone_nrs (persona, property);
- break;
- case "im-addresses":
- add_im_addresses (persona, property);
- break;
- case "urls":
- add_urls (persona, property);
- break;
- case "nickname":
- add_nickname (persona, property);
- break;
- case "birthday":
- add_birthday (persona, property);
- break;
- case "notes":
- add_notes (persona, property);
- break;
- case "postal-addresses":
- add_postal_addresses (persona, property);
- break;
- case "roles":
- add_roles (persona, property);
- break;
- default:
- debug ("Unsupported property: %s", property);
- break;
- }
- }
-
- private void add_roles (Persona persona, string property) {
- unowned var details = persona as RoleDetails;
- if (details == null)
- return;
+ private Gtk.Widget create_widget_for_roles (Chunk chunk) {
+ unowned var roles_chunk = chunk as RolesChunk;
+ return_if_fail (roles_chunk != null);
- var roles = Utils.fields_to_sorted (details.roles);
- var rows = new GLib.List<Gtk.ListBoxRow> ();
- for (uint i = 0; i < roles.get_n_items (); i++) {
- var role = (RoleFieldDetails) roles.get_item (i);
+ var group = new ContactSheetGroup ();
- if (role.value.is_empty ())
+ for (uint i = 0; i < roles_chunk.get_n_items (); i++) {
+ var role = (OrgRole) roles_chunk.get_item (i);
+ if (role.is_empty)
continue;
- var role_str = "";
- if (role.value.title != "") {
- if (role.value.organisation_name != "")
- // TRANSLATORS: "$ROLE at $ORGANISATION", e.g. "CEO at Linux Inc."
- role_str = _("%s at %s").printf (role.value.title, role.value.organisation_name);
- else
- role_str = role.value.title;
- } else {
- role_str = role.value.organisation_name;
- }
-
- var row = new ContactSheetRow (property, role_str);
-
//XXX if no role: set "Organisation" tool tip
- rows.append (row);
+ var row = new ContactSheetRow (chunk.property_name, role.to_string ());
+
+ group.add (row);
}
- this.attach_rows (rows);
+ return group;
}
- private void add_emails (Persona persona, string property) {
- unowned var details = persona as EmailDetails;
- if (details == null)
- return;
+ private Gtk.Widget create_widget_for_emails (Chunk chunk) {
+ unowned var emails_chunk = chunk as EmailAddressesChunk;
+ return_if_fail (emails_chunk != null);
- var emails = Utils.fields_to_sorted (details.email_addresses);
- var rows = new GLib.List<Gtk.ListBoxRow> ();
- for (uint i = 0; i < emails.get_n_items (); i++) {
- var email = (EmailFieldDetails) emails.get_item (i);
+ var group = new ContactSheetGroup ();
- if (email.value == "")
+ for (uint i = 0; i < emails_chunk.get_n_items (); i++) {
+ var email = (EmailAddress) emails_chunk.get_item (i);
+ if (email.is_empty)
continue;
- var row = new ContactSheetRow (property,
- email.value,
- TypeSet.email.format_type (email));
+ var row = new ContactSheetRow (chunk.property_name,
+ email.raw_address,
+ email.get_email_address_type ().display_name);
var button = row.add_button ("mail-send-symbolic");
- button.tooltip_text = _("Send an email to %s".printf (email.value));
+ button.tooltip_text = _("Send an email to %s".printf (email.raw_address));
button.clicked.connect (() => {
- Utils.compose_mail ("%s <%s>".printf(this.individual.display_name, email.value));
+ unowned var window = get_root () as Gtk.Window;
+ Gtk.show_uri (window, email.get_mailto_uri (), 0);
});
- rows.append (row);
+ group.add (row);
}
- this.attach_rows (rows);
+ return group;
}
- private void add_phone_nrs (Persona persona, string property) {
- unowned var phone_details = persona as PhoneDetails;
- if (phone_details == null)
- return;
+ private Gtk.Widget create_widget_for_phone_nrs (Chunk chunk) {
+ unowned var phones_chunk = chunk as PhonesChunk;
+ return_if_fail (phones_chunk != null);
- var phones = Utils.fields_to_sorted (phone_details.phone_numbers);
- var rows = new GLib.List<Gtk.ListBoxRow> ();
- for (uint i = 0; i < phones.get_n_items (); i++) {
- var phone = (PhoneFieldDetails) phones.get_item (i);
+ var group = new ContactSheetGroup ();
- if (phone.value == "")
+ for (uint i = 0; i < phones_chunk.get_n_items (); i++) {
+ var phone = (Phone) phones_chunk.get_item (i);
+ if (phone.is_empty)
continue;
- var row = new ContactSheetRow (property,
- phone.value,
- TypeSet.phone.format_type (phone));
-
-#if HAVE_TELEPATHY
- if (this.store.caller_account != null) {
- var button = row.add_button ("call-start-symbolic");
- button.tooltip_text = _("Start a call");
- button.clicked.connect (() => {
- Utils.start_call (phone.value, this.store.caller_account);
- });
- }
-#endif
-
- rows.append (row);
+ var row = new ContactSheetRow (chunk.property_name,
+ phone.raw_number,
+ phone.get_phone_type ().display_name);
+ group.add (row);
}
- this.attach_rows (rows);
+ return group;
}
- private void add_im_addresses (Persona persona, string property) {
+ private Gtk.Widget? create_widget_for_im_addresses (Chunk chunk) {
// NOTE: We _could_ enable this again, but only for specific services.
// Right now, this just enables a million "Windows Live Messenger" and
// "Jabber", ... fields, which are all resting in their respective coffins.
#if 0
- unowned var im_details = persona as ImDetails;
- if (im_details == null)
- return;
-
- var rows = new GLib.List<Gtk.ListBoxRow> ();
- foreach (var protocol in im_details.im_addresses.get_keys ()) {
- foreach (var id in im_details.im_addresses[protocol]) {
- var row = new ContactSheetRow (property,
- id.value,
- ImService.get_display_name (protocol));
- rows.append (row);
+ unowned var im_addrs_chunk = chunk as ImAddressesChunk;
+ return_if_fail (im_addrs_chunk != null);
+
+ var group = new ContactSheetGroup ();
+
+ for (uint i = 0; i < im_addrs_chunk.get_n_items (); i++) {
+ var im_addr = (ImAddress) im_addrs_chunk.get_item (i);
+ if (im_addr.is_empty)
+ continue;
+
+ var row = new ContactSheetRow (chunk.property_name,
+ im_addr.address,
+ ImService.get_display_name (im_addr.protocol));
+ group.add (row);
}
}
- this.attach_rows (rows);
+ return group;
#endif
+ return null;
}
- private void add_urls (Persona persona, string property) {
- unowned var url_details = persona as UrlDetails;
- if (url_details == null)
- return;
+ private Gtk.Widget create_widget_for_urls (Chunk chunk) {
+ unowned var urls_chunk = chunk as UrlsChunk;
+ return_if_fail (urls_chunk != null);
- var rows = new GLib.List<Gtk.ListBoxRow> ();
- var urls = Utils.fields_to_sorted (url_details.urls);
- for (uint i = 0; i < urls.get_n_items (); i++) {
- var url = (UrlFieldDetails) urls.get_item (i);
+ var group = new ContactSheetGroup ();
- if (url.value == "")
+ for (uint i = 0; i < urls_chunk.get_n_items (); i++) {
+ var url = (Contacts.Url) urls_chunk.get_item (i);
+ if (url.is_empty)
continue;
- var row = new ContactSheetRow (property, url.value);
+ var row = new ContactSheetRow (chunk.property_name, url.raw_url);
var button = row.add_button ("external-link-symbolic");
button.tooltip_text = _("Visit website");
button.clicked.connect (() => {
- unowned var window = button.get_root () as MainWindow;
- if (window == null)
- return;
-
+ unowned var window = button.get_root () as Gtk.Window;
// FIXME: use show_uri_full so we can show errors
Gtk.show_uri (window,
- fallback_to_https (url.value),
+ url.get_absolute_url (),
Gdk.CURRENT_TIME);
});
- rows.append (row);
+ group.add (row);
}
- this.attach_rows (rows);
- }
-
- // When the url doesn't contain a scheme we fallback to http
- // We are sure that the url is a webaddress but GTK falls back to opening a file
- private string fallback_to_https (string url) {
- string scheme = Uri.parse_scheme (url);
- if (scheme == null)
- return "https://" + url;
- return url;
+ return group;
}
- private void add_nickname (Persona persona, string property) {
- unowned var name_details = persona as NameDetails;
- if (name_details == null || name_details.nickname == "")
- return;
+ private Gtk.Widget create_widget_for_nickname (Chunk chunk) {
+ unowned var nickname_chunk = chunk as NicknameChunk;
+ return_if_fail (nickname_chunk != null || nickname_chunk.nickname != null);
- var row = new ContactSheetRow (property, name_details.nickname);
- this.attach_row (row);
+ var row = new ContactSheetRow (chunk.property_name, nickname_chunk.nickname);
+ return new ContactSheetGroup.single_row (row);
}
- private void add_birthday (Persona persona, string property) {
- unowned var birthday_details = persona as BirthdayDetails;
- if (birthday_details == null || birthday_details.birthday == null)
- return;
+ private Gtk.Widget create_widget_for_birthday (Chunk chunk) {
+ unowned var birthday_chunk = chunk as BirthdayChunk;
+ return_if_fail (birthday_chunk != null || birthday_chunk.birthday != null);
- var birthday_str = birthday_details.birthday.to_local ().format ("%x");
+ var birthday_str = birthday_chunk.birthday.to_local ().format ("%x");
// Compare month and date so we can put a reminder
string? subtitle = null;
int bd_m, bd_d, now_m, now_d;
- birthday_details.birthday.to_local ().get_ymd (null, out bd_m, out bd_d);
+ birthday_chunk.birthday.to_local ().get_ymd (null, out bd_m, out bd_d);
new DateTime.now_local ().get_ymd (null, out now_m, out now_d);
if (bd_m == now_m && bd_d == now_d) {
subtitle = _("Their birthday is today! 🎉");
}
- var row = new ContactSheetRow (property, birthday_str, subtitle);
- this.attach_row (row);
+ var row = new ContactSheetRow (chunk.property_name, birthday_str, subtitle);
+ return new ContactSheetGroup.single_row (row);
}
- private void add_notes (Persona persona, string property) {
- unowned var note_details = persona as NoteDetails;
- if (note_details == null)
- return;
+ private Gtk.Widget create_widget_for_notes (Chunk chunk) {
+ unowned var notes_chunk = chunk as NotesChunk;
+ return_if_fail (notes_chunk != null);
- var rows = new GLib.List<Gtk.ListBoxRow> ();
- foreach (var note in note_details.notes) {
- if (note.value == "")
+ var group = new ContactSheetGroup ();
+
+ for (uint i = 0; i < notes_chunk.get_n_items (); i++) {
+ var note = (Note) notes_chunk.get_item (i);
+ if (note.is_empty)
continue;
- var row = new ContactSheetRow (property, note.value);
- rows.append (row);
+ var row = new ContactSheetRow (chunk.property_name, note.text);
+ group.add (row);
}
- this.attach_rows (rows);
+ return group;
}
- private void add_postal_addresses (Persona persona, string property) {
- unowned var addr_details = persona as PostalAddressDetails;
- if (addr_details == null)
- return;
+ private Gtk.Widget create_widget_for_postal_addresses (Chunk chunk) {
+ unowned var addresses_chunk = chunk as AddressesChunk;
+ return_if_fail (addresses_chunk != null);
// Check outside of the loop if we have a "maps:" URI handler
var appinfo = AppInfo.get_default_for_uri_scheme ("maps");
var map_uris_supported = (appinfo != null);
debug ("Opening 'maps:' URIs supported: %s", map_uris_supported.to_string ());
- var rows = new GLib.List<Gtk.ListBoxRow> ();
- foreach (var addr in addr_details.postal_addresses) {
- if (addr.value.is_empty ())
+ var group = new ContactSheetGroup ();
+
+ for (uint i = 0; i < addresses_chunk.get_n_items (); i++) {
+ var address = (Address) addresses_chunk.get_item (i);
+ if (address.is_empty)
continue;
- var row = new ContactSheetRow (property,
- string.joinv ("\n", Utils.format_address (addr.value)),
- TypeSet.general.format_type (addr));
+ var row = new ContactSheetRow (chunk.property_name,
+ address.to_string ("\n"),
+ address.get_address_type ().display_name);
if (map_uris_supported) {
var button = row.add_button ("map-symbolic");
button.tooltip_text = _("Show on the map");
button.clicked.connect (() => {
- unowned var window = button.get_root () as MainWindow;
- if (window == null)
- return;
-
- var uri = Utils.create_maps_uri (addr.value);
+ unowned var window = button.get_root () as Gtk.Window;
+ var uri = address.to_maps_uri ();
// FIXME: use show_uri_full so we can show errors
Gtk.show_uri (window, uri, Gdk.CURRENT_TIME);
});
}
- rows.append (row);
+ group.add (row);
+ }
+
+ return group;
+ }
+}
+
+public class Contacts.ContactSheetGroup : Adw.PreferencesGroup {
+
+ construct {
+ add_css_class ("contacts-sheet-property");
+ }
+
+ public ContactSheetGroup.single_row (ContactSheetRow row) {
+ add (row);
+ }
+}
+
+public class Contacts.ContactSheetRow : Adw.ActionRow {
+
+ construct {
+ this.title_selectable = true;
+ }
+
+ public ContactSheetRow (string property_name, string title, string? subtitle = null) {
+ unowned var icon_name = Utils.get_icon_name_for_property (property_name);
+ if (icon_name != null) {
+ var icon = new Gtk.Image.from_icon_name (icon_name);
+ icon.add_css_class ("contacts-property-icon");
+ icon.tooltip_text = Utils.get_display_name_for_property (property_name);
+ this.add_prefix (icon);
}
- this.attach_rows (rows);
+ this.title = Markup.escape_text (title);
+
+ if (subtitle != null)
+ this.subtitle = subtitle;
+ }
+
+ public Gtk.Button add_button (string icon) {
+ var button = new Gtk.Button.from_icon_name (icon);
+ button.valign = Gtk.Align.CENTER;
+ button.add_css_class ("flat");
+ this.add_suffix (button);
+ return button;
}
}
diff --git a/src/contacts-editor-persona.vala b/src/contacts-editor-persona.vala
deleted file mode 100644
index 365e25f..0000000
--- a/src/contacts-editor-persona.vala
+++ /dev/null
@@ -1,165 +0,0 @@
-/*
- * Copyright (C) 2019 Purism SPC
- * Author: Julian Sparber <julian.sparber@puri.sm>
- * Copyright (C) 2021 Niels De Graef <nielsdegraef@gmail.com>
- *
- * This program is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation; either version 2 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- */
-
-using Folks;
-
-/**
- * A widget representing a persona in the {@link ContactEditor}.
- */
-public class Contacts.EditorPersona : Gtk.Box {
-
- // List of important properties and a list of secoundary properties
- private const string[] PROPERTIES = {
- "email-addresses",
- "phone-numbers"
- };
- private const string[] OTHER_PROPERTIES = {
- "im-addresses",
- "roles",
- "urls",
- "nickname",
- "birthday",
- "postal-addresses",
- "notes"
- };
-
- private unowned Folks.Persona persona;
- private unowned Gtk.Box header;
- private unowned Gtk.Box content;
-
- private unowned Folks.IndividualAggregator aggregator;
-
- construct {
- var _header = new Gtk.Box (Gtk.Orientation.HORIZONTAL, 0);
- this.append (_header);
- this.header = _header;
-
- var listbox = new Gtk.Box (Gtk.Orientation.VERTICAL, 0);
- this.content = listbox;
- this.content.add_css_class ("boxed-list");
- this.append (this.content);
- }
-
- public EditorPersona (Persona persona, IndividualAggregator aggregator) {
- Object (orientation: Gtk.Orientation.VERTICAL, spacing: 6);
- this.persona = persona;
- this.aggregator = aggregator;
- create_label ();
- // TODO: implement the possibility f changing the addressbook of a persona
-
- // Add most important properites
- foreach (unowned var property in PROPERTIES) {
- debug ("Create property entry for %s", property);
- var prop_editor = new EditorProperty (persona, property);
-
- for (int i = 0; i < prop_editor.get_n_items (); i++) {
- var row = (EditorPropertyRow) prop_editor.get_item (i);
- row.show_with_animation (false);
- connect_row (row);
- this.content.append (row);
- }
- }
-
- // Add less important properties when the show_more button is clicked
- var show_more_button = new Gtk.Button ();
- var show_more_content = new Adw.ButtonContent ();
- show_more_content.icon_name = "view-more-symbolic";
- show_more_content.label = _("Show More");
- show_more_button.set_child (show_more_content);
- show_more_button.halign = Gtk.Align.CENTER;
- show_more_button.add_css_class ("flat");
- show_more_button.clicked.connect ((current_row) => {
- foreach (unowned string property in OTHER_PROPERTIES) {
- debug ("Create property entry for %s", property);
- var prop_editor = new EditorProperty (persona, property);
-
- for (int i = 0; i < prop_editor.get_n_items (); i++) {
- var row = (EditorPropertyRow) prop_editor.get_item (i);
- connect_row (row);
- this.content.append (row);
- row.show_with_animation ();
- }
- }
- this.content.remove (show_more_button);
- });
- this.content.append (show_more_button);
- }
-
- private void connect_row (EditorPropertyRow row) {
- row.notify["is-empty"].connect (() => {
- var empty_rows_count = this.count_empty_rows (row.ptype);
- if (row.is_empty) {
- // destroy all rows of our type which is not us
- this.destroy_empty_rows (row, row.ptype);
- }
- if (!row.is_empty && empty_rows_count == 0) {
- // We are sure that we only created one new row
- var new_rows = new EditorProperty (persona, row.ptype, true);
- if (new_rows.get_n_items () > 0) {
- var first_row = (EditorPropertyRow) new_rows.get_item (0);
- this.content.insert_child_after (first_row, row);
- connect_row (first_row);
- } else {
- debug ("Couldn't add new row with type %s", row.ptype);
- }
- }
- });
- }
-
- private uint count_empty_rows (string type) {
- uint count = 0;
- for (unowned Gtk.Widget? child = this.content.get_first_child ();
- child != null;
- child = child.get_next_sibling ()) {
- unowned var prop = (child as EditorPropertyRow);
- if (prop != null && !prop.is_removed && prop.is_empty && prop.ptype == type) {
- count++;
- }
- }
- return count;
- }
-
- private void destroy_empty_rows (Gtk.Widget current_row, string type) {
- for (unowned Gtk.Widget? child = this.content.get_first_child ();
- child != null;
- child = child.get_next_sibling ()) {
- if (current_row == child)
- continue;
-
- unowned var prop = (child as EditorPropertyRow);
- if (prop != null && !prop.is_removed && prop.is_empty && prop.ptype == type) {
- prop.remove ();
- }
- }
- }
-
- private void create_label () {
- string title = "";
- unowned var fake_persona = this.persona as FakePersona;
- if (fake_persona != null && fake_persona.real_persona != null) {
- title = fake_persona.real_persona.store.display_name;
- } else {
- title = this.aggregator.primary_store.display_name;
- }
-
- Gtk.Label addressbook = new Gtk.Label (title);
- addressbook.add_css_class ("heading");
- this.header.append (addressbook);
- }
-}
diff --git a/src/contacts-editor-property.vala b/src/contacts-editor-property.vala
deleted file mode 100644
index 85c40e2..0000000
--- a/src/contacts-editor-property.vala
+++ /dev/null
@@ -1,761 +0,0 @@
-/*
- * Copyright (C) 2019 Purism SPC
- * Author: Julian Sparber <julian.sparber@puri.sm>
- * Copyright (C) 2021 Niels De Graef <nielsdegraef@gmail.com>
- *
- * This program is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation; either version 2 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- */
-
-using Adw;
-using Folks;
-
-public class Contacts.BirthdayEditor : Gtk.Dialog {
-
- private unowned Gtk.SpinButton day_spin;
- private unowned Gtk.ComboBoxText month_combo;
- private unowned Gtk.SpinButton year_spin;
-
- public bool is_set { get; set; default = false; }
-
- public signal void changed ();
-
- construct {
- // The grid that will contain the Y/M/D fields
- var grid = new Gtk.Grid ();
- grid.column_spacing = 12;
- grid.row_spacing = 12;
- grid.add_css_class ("contacts-editor-birthday");
- ((Gtk.Box) this.get_content_area ()).append (grid);
-
- // Day
- var d_spin = new Gtk.SpinButton.with_range (1.0, 31.0, 1.0);
- d_spin.digits = 0;
- d_spin.numeric = true;
- this.day_spin = d_spin;
-
- // Month
- var m_combo = new Gtk.ComboBoxText ();
- var january = new DateTime.local (1, 1, 1, 1, 1, 1);
- for (int i = 0; i < 12; i++) {
- var month = january.add_months (i);
- m_combo.append_text (month.format ("%B"));
- }
- m_combo.hexpand = true;
- this.month_combo = m_combo;
-
- // Year
- var y_spin = new Gtk.SpinButton.with_range (1800, 3000, 1);
- y_spin.set_digits (0);
- y_spin.numeric = true;
- this.year_spin = y_spin;
-
- // Create grid and labels
- Gtk.Label day = new Gtk.Label (_("Day"));
- day.set_halign (Gtk.Align.END);
- grid.attach (day, 0, 0);
- grid.attach (day_spin, 1, 0);
- Gtk.Label month = new Gtk.Label (_("Month"));
- month.set_halign (Gtk.Align.END);
- grid.attach (month, 0, 1);
- grid.attach (month_combo, 1, 1);
- Gtk.Label year = new Gtk.Label (_("Year"));
- year.set_halign (Gtk.Align.END);
- grid.attach (year, 0, 2);
- grid.attach (year_spin, 1, 2);
-
- this.title = _("Change Birthday");
- add_buttons (_("Set"), Gtk.ResponseType.OK,
- _("Cancel"), Gtk.ResponseType.CANCEL,
- null);
- var ok_button = this.get_widget_for_response (Gtk.ResponseType.OK);
- ok_button.add_css_class ("suggested-action");
- this.response.connect ((id) => {
- switch (id) {
- case Gtk.ResponseType.OK:
- this.is_set = true;
- changed ();
- break;
- case Gtk.ResponseType.CANCEL:
- break;
- }
- this.destroy ();
- });
- }
-
- public BirthdayEditor (Gtk.Window window, DateTime birthday) {
- Object (transient_for: window, use_header_bar: 1, modal: true);
-
- // Don't forget to change to local timezone first
- var bday_local = birthday.to_local ();
- this.day_spin.set_value ((double) bday_local.get_day_of_month ());
- this.month_combo.set_active (bday_local.get_month () - 1);
- this.year_spin.set_value ((double) bday_local.get_year ());
-
- update_date ();
- month_combo.changed.connect (() => {
- update_date ();
- });
- year_spin.value_changed.connect (() => {
- update_date ();
- });
- }
-
- /**
- * Returns the selected birthday (in UTC timezone)
- */
- public GLib.DateTime get_birthday () {
- return new GLib.DateTime.local (year_spin.get_value_as_int (),
- month_combo.get_active () + 1,
- day_spin.get_value_as_int (),
- 0, 0, 0).to_utc ();
- }
-
- private void update_date() {
- const int[] month_of_31 = {3, 5, 8, 10};
-
- if (this.month_combo.get_active () in month_of_31) {
- this.day_spin.set_range (1, 30);
- } else if (this.month_combo.get_active () == 1) {
- if (this.year_spin.get_value_as_int () % 400 == 0 ||
- (this.year_spin.get_value_as_int () % 4 == 0 &&
- this.year_spin.get_value_as_int () % 100 != 0)) {
- this.day_spin.set_range (1, 29);
- } else {
- this.day_spin.set_range (1, 28);
- }
- } else {
- this.day_spin.set_range (1, 31);
- }
- }
-}
-
-public class Contacts.AddressEditor : Gtk.Box {
- private Gtk.Entry? entries[7]; /* must be the number of elements in postal_element_props */
-
- private const string[] postal_element_props = {"street", "extension", "locality", "region", "postal_code", "po_box", "country"};
- private static string[] postal_element_names = {_("Street"), _("Extension"), _("City"), _("State/Province"), _("Zip/Postal Code"), _("PO box"), _("Country")};
-
- public signal void changed ();
-
- construct {
- this.add_css_class ("contacts-editor-address");
-
- this.hexpand = true;
- this.orientation = Gtk.Orientation.VERTICAL;
- }
-
- public AddressEditor (PostalAddressFieldDetails details) {
- for (int i = 0; i < entries.length; i++) {
- string postal_part;
- details.value.get (AddressEditor.postal_element_props[i], out postal_part);
-
- this.entries[i] = new Gtk.Entry ();
- this.entries[i].hexpand = true;
- this.entries[i].placeholder_text = AddressEditor.postal_element_names[i];
- this.entries[i].add_css_class ("flat");
-
- if (postal_part != null)
- this.entries[i].text = postal_part;
-
- append (this.entries[i]);
-
- var prop_name = AddressEditor.postal_element_props[i];
- this.entries[i].changed.connect ((editable) => {
- details.value.set (prop_name, editable.text);
- changed ();
- });
- }
- }
-
- public bool is_empty () {
- foreach (var entry in this.entries) {
- if (entry.get_text () != "") {
- return false;
- }
- }
- return true;
- }
-}
-
-public class Contacts.RoleEditor : Gtk.Box {
-
- private Gtk.Entry role_entry;
- private Gtk.Entry organisation_entry;
-
- public signal void changed ();
-
- construct {
- this.add_css_class ("contacts-editor-role");
- this.hexpand = true;
- this.orientation = Gtk.Orientation.VERTICAL;
-
- this.role_entry = new Gtk.Entry ();
- this.role_entry.hexpand = true;
- this.role_entry.placeholder_text = _("Role");
- this.role_entry.add_css_class ("flat");
- this.role_entry.changed.connect ((_) => { changed(); });
- append (this.role_entry);
-
- this.organisation_entry = new Gtk.Entry ();
- this.organisation_entry.hexpand = true;
- this.organisation_entry.placeholder_text = _("Organisation");
- this.organisation_entry.add_css_class ("flat");
- this.organisation_entry.changed.connect ((_) => { changed(); });
- append (this.organisation_entry);
- }
-
- public RoleEditor (RoleFieldDetails details) {
- details.value.bind_property ("title", this.role_entry, "text",
- BindingFlags.BIDIRECTIONAL | BindingFlags.SYNC_CREATE);
- details.value.bind_property ("organisation-name", this.organisation_entry, "text",
- BindingFlags.BIDIRECTIONAL | BindingFlags.SYNC_CREATE);
- }
-
- public bool is_empty () {
- return this.role_entry.get_text () != "" &&
- this.organisation_entry.get_text () != "";
- }
-}
-
-/**
- * Basic widget to show a single property of a contact (for example an email
- * address, a birthday, ...). It can show itself using a GtkRevealer animation.
- *
- * To edit the value of the property, you should supply a widget and set it as
- * the main widget.
- */
-public class Contacts.EditorPropertyRow : Adw.Bin {
-
- private unowned Gtk.Revealer revealer;
- private unowned Gtk.ListBox listbox;
-
- public bool is_empty { get; set; default = true; }
- public bool is_removed { get; set; default = false; }
- public bool removable { get; set; default = false; }
-
- /** Internal type name of the property */
- public string ptype { get; construct; }
-
- construct {
- var _revealer = new Gtk.Revealer ();
- _revealer.bind_property ("reveal-child", this, "is-removed",
- BindingFlags.BIDIRECTIONAL | BindingFlags.INVERT_BOOLEAN);
- this.child = _revealer;
- this.revealer = _revealer;
-
- var list_box = new Gtk.ListBox ();
- this.listbox = list_box;
- this.listbox.selection_mode = Gtk.SelectionMode.NONE;
- this.listbox.activate_on_single_click = true;
- this.listbox.add_css_class ("boxed-list");
- this.listbox.add_css_class ("contacts-editor-property");
- this.revealer.set_child (listbox);
- }
-
- public EditorPropertyRow (string type) {
- Object (ptype: type);
- }
-
- public void show_with_animation (bool animate = true) {
- if (!animate) {
- var duration = this.revealer.get_transition_duration ();
- this.revealer.set_reveal_child (true);
- this.revealer.set_transition_duration (duration);
- } else {
- this.revealer.set_reveal_child (true);
- }
- }
-
- // This hides the widget with an animation and then destroys it
- public void remove () {
- debug ("Property %s is removed", this.ptype);
-
- this.revealer.set_reveal_child (false);
-
- // Remove the separator during the animation to make it look a little better
- Timeout.add (this.revealer.get_transition_duration ()/2, () => {
- return false;
- });
-
- this.revealer.notify["child-revealed"].connect (() => {
- this.destroy ();
- });
- }
-
- /**
- * Setter for the main widget, which can be used to actually edit the property
- */
- public void set_main_widget (Gtk.Widget widget, bool add_icon = true) {
- var row = new Gtk.ListBoxRow ();
- row.focusable = false;
-
- var box = new Gtk.Box (Gtk.Orientation.HORIZONTAL, 0);
- widget.hexpand = true;
- row.set_child (box);
-
- // Start with the icon (if known)
- if (add_icon) {
- unowned var icon_name = Utils.get_icon_name_for_property (this.ptype);
- if (icon_name != null) {
- var icon = new Gtk.Image.from_icon_name (icon_name);
- icon.valign = Gtk.Align.START;
- icon.add_css_class ("contacts-property-icon");
- icon.tooltip_text = Utils.get_display_name_for_property (this.ptype);
- box.prepend (icon);
- }
- }
-
- // Set the actual widget
- // (mimic Adw.ActionRow's "activatable-widget")
- widget.add_css_class ("contacts-editor-main-widget");
- box.append (widget);
- this.listbox.row_activated.connect ((activated_row) => {
- if (row == activated_row)
- widget.mnemonic_activate (false);
- });
-
- // Add a delete buton if needed
- if (this.removable) {
- var delete_button = new Gtk.Button.from_icon_name ("user-trash-symbolic");
- delete_button.tooltip_text = _("Delete field");
- this.bind_property ("is-empty", delete_button, "sensitive", BindingFlags.SYNC_CREATE | BindingFlags.INVERT_BOOLEAN);
-
- delete_button.clicked.connect ((b) => { this.remove (); });
-
- box.append (delete_button);
- }
-
- this.listbox.append (row);
- }
-
- /**
- * An alternative to set_main_widget() that creates and adds an AdwEntryRow
- * to the list, and makes sure the "is-empty" property is updated.
- */
- public Adw.EntryRow set_main_entry_row(string text, string? placeholder = null) {
- var row = new Adw.EntryRow();
- row.title = placeholder;
- row.text = text;
-
- unowned var icon_name = Utils.get_icon_name_for_property (this.ptype);
- if (icon_name != null) {
- row.add_prefix(new Gtk.Image.from_icon_name (icon_name));
- }
-
- if (this.removable) {
- var delete_button = new Gtk.Button.from_icon_name ("user-trash-symbolic");
- delete_button.tooltip_text = _("Delete field");
- this.bind_property ("is-empty", delete_button, "sensitive", BindingFlags.SYNC_CREATE | BindingFlags.INVERT_BOOLEAN);
-
- delete_button.clicked.connect ((b) => { this.remove (); });
-
- row.add_suffix (delete_button);
- }
-
- this.is_empty = (text == "");
- row.changed.connect (() => {
- this.is_empty = (row.text == "");
- });
-
- this.listbox.append (row);
-
- return row;
- }
-
- // Adds an extra row for a type combo, to choose between e.g. "Home" or "Work"
- public void add_type_combo (Gee.Set<AbstractFieldDetails> details_set,
- TypeSet combo_type,
- AbstractFieldDetails details) {
- var row = new TypeComboRow (combo_type);
- row.title = _("Label");
- row.set_selected_from_field_details (details);
- this.listbox.append (row);
-
- row.notify["selected-item"].connect ((obj, pspec) => {
- unowned var descr = row.selected_descriptor;
- descr.save_to_field_details (details);
- // Workaround: we shouldn't do a manual signal
- ((FakeHashSet) details_set).changed ();
- debug ("Property phone changed");
- });
- }
-}
-
-/**
- * A widget representing a property of a persona in the editor {@link Contact}.
- *
- * We can have more then one field in a single property
- * (for example: emails, phone nrs, ...), so it implements a
- * {@link GLib.ListModel}.
- */
-public class Contacts.EditorProperty : Object, ListModel {
-
- private GenericArray<EditorPropertyRow> rows;
-
- public bool writeable { get; private set; default = false; }
-
- construct {
- this.rows = new GenericArray<EditorPropertyRow> (1);
- }
-
- public EditorProperty (Persona persona, string property_name, bool only_new = false) {
- foreach (unowned string s in persona.writeable_properties) {
- if (s == property_name) {
- this.writeable = true;
- break;
- }
- }
-
- create_for_property (persona, property_name, only_new);
- }
-
- public Object? get_item (uint i) {
- if (i > this.rows.length)
- return null;
-
- return this.rows[i];
- }
-
- public uint get_n_items () {
- return this.rows.length;
- }
-
- public GLib.Type get_item_type () {
- return typeof (EditorPropertyRow);
- }
-
- private void create_for_property (Persona p, string prop_name, bool only_new) {
- switch (prop_name) {
- case "email-addresses":
- unowned var details = p as EmailDetails;
- if (details != null) {
- var emails = Utils.fields_to_sorted (details.email_addresses);
- if (!only_new)
- for (uint i = 0; i < emails.get_n_items (); i++) {
- var email = (EmailFieldDetails) emails.get_item (i);
- this.rows.add (create_for_email (details.email_addresses, email));
- }
- if (this.writeable)
- this.rows.add (create_for_email (details.email_addresses));
- }
- break;
- case "phone-numbers":
- unowned var details = p as PhoneDetails;
- if (details != null) {
- var phones = Utils.fields_to_sorted (details.phone_numbers);
- if (!only_new)
- for (uint i = 0; i < phones.get_n_items (); i++) {
- var phone = (PhoneFieldDetails) phones.get_item (i);
- this.rows.add (create_for_phone (details.phone_numbers, phone));
- }
- if (this.writeable)
- this.rows.add (create_for_phone (details.phone_numbers));
- }
- break;
- case "urls":
- unowned var details = p as UrlDetails;
- if (details != null) {
- var urls = Utils.fields_to_sorted (details.urls);
- if (!only_new)
- for (uint i = 0; i < urls.get_n_items (); i++) {
- var url = (UrlFieldDetails) urls.get_item (i);
- this.rows.add (create_for_url (details.urls, url));
- }
- this.rows.add (create_for_url (details.urls));
- }
- break;
- case "nickname":
- unowned var name_details = p as NameDetails;
- if (name_details != null && name_details.nickname != null && !only_new) {
- this.rows.add (create_for_nick (name_details));
- }
- break;
- case "birthday":
- unowned var birthday_details = p as BirthdayDetails;
- if (birthday_details != null && !only_new) {
- this.rows.add (create_for_birthday (birthday_details));
- }
- break;
- case "notes":
- unowned var note_details = p as NoteDetails;
- if (note_details != null) {
- if (!only_new)
- foreach (var note in note_details.notes) {
- this.rows.add (create_for_note (note_details.notes, note));
- }
- if (this.writeable)
- this.rows.add (create_for_note (note_details.notes));
- }
- break;
- case "postal-addresses":
- unowned var address_details = p as PostalAddressDetails;
- if (address_details != null) {
- if (!only_new)
- foreach (var addr in address_details.postal_addresses) {
- this.rows.add (create_for_address (address_details.postal_addresses, addr));
- }
- if (this.writeable)
- this.rows.add (create_for_address (address_details.postal_addresses));
- }
- break;
- case "roles":
- unowned var role_details = p as RoleDetails;
- if (role_details != null) {
- if (!only_new) {
- var roles = Utils.fields_to_sorted (role_details.roles);
- for (uint i = 0; i < roles.get_n_items (); i++) {
- var role = (RoleFieldDetails) roles.get_item (i);
- this.rows.add (create_for_role (role_details.roles, role));
- }
- }
- if (this.writeable)
- this.rows.add (create_for_role (role_details.roles));
- }
- break;
- }
- }
-
- private EditorPropertyRow create_for_email (Gee.Set<AbstractFieldDetails> details_set,
- EmailFieldDetails? details = null) {
- if (details == null) {
- var parameters = new Gee.HashMultiMap<string, string> ();
- parameters["type"] = "PERSONAL";
- var new_details = new EmailFieldDetails ("", parameters);
- details_set.add (new_details);
- details = new_details;
- }
-
- var box = new EditorPropertyRow ("email-addresses");
- box.sensitive = this.writeable;
-
- var entry = box.set_main_entry_row (details.value, _("Add email"));
- entry.set_input_purpose (Gtk.InputPurpose.EMAIL);
- entry.changed.connect (() => {
- details.value = entry.get_text ();
- // Workaround: we shouldn't do a manual signal
- ((FakeHashSet) details_set).changed ();
- debug ("Property email changed");
- });
-
- box.add_type_combo (details_set, TypeSet.email, details);
-
- return box;
- }
-
- private EditorPropertyRow create_for_phone (Gee.Set<AbstractFieldDetails> details_set,
- PhoneFieldDetails? details = null) {
- if (details == null) {
- var parameters = new Gee.HashMultiMap<string, string> ();
- parameters["type"] = "CELL";
- var new_details = new PhoneFieldDetails ("", parameters);
- details_set.add (new_details);
- details = new_details;
- }
-
- var box = new EditorPropertyRow ("phone-numbers");
- box.sensitive = this.writeable;
-
- var entry = box.set_main_entry_row (details.value, _("Add phone number"));
- entry.set_input_purpose (Gtk.InputPurpose.PHONE);
- entry.changed.connect (() => {
- details.value = entry.text;
- // Workaround: we shouldn't do a manual signal
- ((FakeHashSet) details_set).changed ();
- debug ("Property type changed");
- });
-
- box.add_type_combo (details_set, TypeSet.phone, details);
-
- return box;
- }
-
- // TODO: add support for different types of urls
- private EditorPropertyRow create_for_url (Gee.Set<AbstractFieldDetails> details_set,
- UrlFieldDetails? details = null) {
- if (details == null) {
- var parameters = new Gee.HashMultiMap<string, string> ();
- parameters["type"] = "PERSONAL";
- var new_details = new UrlFieldDetails ("", parameters);
- details_set.add (new_details);
- details = new_details;
- }
-
- var box = new EditorPropertyRow ("urls");
- box.sensitive = this.writeable;
-
- var entry = box.set_main_entry_row (details.value, _("https://example.com"));
- entry.set_input_purpose (Gtk.InputPurpose.URL);
- entry.changed.connect (() => {
- details.value = entry.get_text ();
- // Workaround: we shouldn't do a manual signal
- ((FakeHashSet) details_set).changed ();
- debug ("Property type changed");
- });
-
- return box;
- }
-
- private EditorPropertyRow create_for_nick (NameDetails details) {
- var box = new EditorPropertyRow ("nickname");
- box.sensitive = this.writeable;
-
- var entry = box.set_main_entry_row (details.nickname, _("Nickname"));
- entry.set_input_purpose (Gtk.InputPurpose.NAME);
- entry.changed.connect (() => {
- details.nickname = entry.text;
- debug ("Nickname changed");
- });
-
- return box;
- }
-
- // TODO: support different types of notes
- private EditorPropertyRow create_for_note (Gee.Set<NoteFieldDetails> details_set,
- NoteFieldDetails? details = null) {
- if (details == null) {
- var parameters = new Gee.HashMultiMap<string, string> ();
- parameters["type"] = "PERSONAL";
- var new_details = new NoteFieldDetails ("", parameters);
- details_set.add (new_details);
- details = new_details;
- }
- var box = new EditorPropertyRow ("notes");
-
- var sw = new Gtk.ScrolledWindow ();
- sw.focusable = false;
- sw.has_frame = false;
- sw.set_size_request (-1, 100);
- box.set_main_widget (sw);
-
- var textview = new Gtk.TextView ();
- textview.get_buffer ().set_text (details.value);
- textview.hexpand = true;
- sw.set_child (textview);
-
- textview.get_buffer ().changed.connect (() => {
- Gtk.TextIter start, end;
- textview.get_buffer ().get_start_iter (out start);
- textview.get_buffer ().get_end_iter (out end);
- details.value = textview.get_buffer ().get_text (start, end, true);
- // Workaround: we shouldn't do a manual signal
- ((FakeHashSet) details_set).changed ();
- debug ("Property changed");
- box.is_empty = details.value == "";
- });
-
- box.sensitive = this.writeable;
- return box;
- }
-
- private EditorPropertyRow create_for_birthday (BirthdayDetails? details) {
- var date = details.birthday ?? new DateTime.now ();
-
- Gtk.Button button;
- if (details.birthday == null) {
- button = new Gtk.Button.with_label (_("Set Birthday"));
- } else {
- button = new Gtk.Button.with_label (details.birthday.to_local ().format ("%x"));
- }
-
- var box = new EditorPropertyRow ("birthday");
- box.set_main_widget (button);
-
- button.clicked.connect (() => {
- unowned var parent_window = button.get_root () as Gtk.Window;
- if (parent_window != null) {
- var dialog = new BirthdayEditor (parent_window, date);
-
- dialog.changed.connect (() => {
- if (dialog.is_set) {
- details.birthday = dialog.get_birthday ();
- button.set_label (details.birthday.to_local ().format ("%x"));
- box.is_empty = false;
- }
- });
- dialog.show ();
- }
- });
-
- box.is_empty = details.birthday == null;
-
- var delete_button = new Gtk.Button.from_icon_name ("user-trash-symbolic");
- delete_button.tooltip_text = _("Delete field");
- delete_button.set_valign (Gtk.Align.START);
- box.bind_property ("is-empty", delete_button, "sensitive", BindingFlags.SYNC_CREATE | BindingFlags.INVERT_BOOLEAN);
- // box.container.append (delete_button); XXX
-
- delete_button.clicked.connect (() => {
- debug ("Birthday removed");
- details.birthday = null;
- box.is_empty = true;
- button.set_label (_("Set Birthday"));
- });
-
- box.sensitive = this.writeable;
- return box;
- }
-
- private EditorPropertyRow create_for_address (Gee.Set<PostalAddressFieldDetails> details_set,
- PostalAddressFieldDetails? details = null) {
- if (details == null) {
- var parameters = new Gee.HashMultiMap<string, string> ();
- parameters["type"] = "HOME";
- var address = new PostalAddress (null, null, null, null, null, null, null, null, null);
- var new_details = new PostalAddressFieldDetails (address, parameters);
- details_set.add (new_details);
- details = new_details;
- }
- var box = new EditorPropertyRow ("postal-addresses");
-
- var value_address = new AddressEditor (details);
- box.set_main_widget (value_address);
- box.is_empty = value_address.is_empty ();
-
- box.add_type_combo (details_set, TypeSet.general, details);
-
- value_address.changed.connect (() => {
- // Workaround: we shouldn't do a manual signal
- ((FakeHashSet) details_set).changed ();
- debug ("Address changed");
- box.is_empty = value_address.is_empty ();
- });
-
- box.sensitive = this.writeable;
- return box;
- }
-
- private EditorPropertyRow create_for_role (Gee.Set<RoleFieldDetails> details_set,
- RoleFieldDetails? details = null) {
- if (details == null) {
- var new_details = new RoleFieldDetails (new Role ());
- details_set.add (new_details);
- details = new_details;
- }
- var box = new EditorPropertyRow ("roles");
-
- var role_editor = new RoleEditor (details);
- box.set_main_widget (role_editor);
- box.is_empty = role_editor.is_empty ();
-
- role_editor.changed.connect (() => {
- // Workaround: we shouldn't do a manual signal
- ((FakeHashSet) details_set).changed ();
- debug ("Role changed");
- box.is_empty = role_editor.is_empty ();
- });
-
- box.sensitive = this.writeable;
- return box;
- }
-}
diff --git a/src/contacts-fake-persona-store.vala b/src/contacts-fake-persona-store.vala
deleted file mode 100644
index 283ad59..0000000
--- a/src/contacts-fake-persona-store.vala
+++ /dev/null
@@ -1,612 +0,0 @@
-/*
- * Copyright (C) 2011 Alexander Larsson <alexl@redhat.com>
- *
- * This program is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation; either version 2 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- */
-
-using Folks;
-
-/**
- * A "dummy" store which is used to have an equivalent of a PersonaStore for a
- * FakePersona.
- */
-public class Contacts.FakePersonaStore : PersonaStore {
- public static FakePersonaStore _the_store;
- public static FakePersonaStore the_store() {
- if (_the_store == null)
- _the_store = new FakePersonaStore ();
- return _the_store;
- }
- private Gee.HashMap<string, Persona> _personas;
- private Gee.Map<string, Persona> _personas_ro;
-
- public override string type_id { get { return "fake"; } }
-
- public FakePersonaStore () {
- Object (id: "uri", display_name: "fake store");
- this._personas = new Gee.HashMap<string, Persona> ();
- this._personas_ro = this._personas.read_only_view;
- }
-
- public override Gee.Map<string, Persona> personas {
- get { return this._personas_ro; }
- }
-
- public override MaybeBool can_add_personas { get { return MaybeBool.FALSE; } }
- public override MaybeBool can_alias_personas { get { return MaybeBool.FALSE; } }
- public override MaybeBool can_group_personas { get { return MaybeBool.FALSE; } }
- public override MaybeBool can_remove_personas { get { return MaybeBool.TRUE; } }
- public override bool is_prepared { get { return true; } }
- public override bool is_quiescent { get { return true; } }
- private string[] _always_writeable_properties = {};
- public override string[] always_writeable_properties { get { return this._always_writeable_properties; } }
-
- public override async void prepare () throws GLib.Error { }
-
- public override async Persona? add_persona_from_details (HashTable<string, Value?> details) throws Folks.PersonaStoreError {
- return null;
- }
-
- public override async void remove_persona (Persona persona) throws Folks.PersonaStoreError {
- }
-}
-
-/**
- * A "dummy" Persona which is used when creating a new contact
- * The FakePersona is used as a placeholder till we get the real persona from folks
- * It needs to implement all Details we support so that we don't loise any information
- */
-const string BACKEND_NAME = "fake-store";
-
-public class Contacts.FakePersona : Persona,
- AvatarDetails,
- BirthdayDetails,
- EmailDetails,
- ImDetails,
- NameDetails,
- NoteDetails,
- PhoneDetails,
- RoleDetails,
- UrlDetails,
- PostalAddressDetails {
-
- private HashTable<string, Value?> properties;
- // Keep track of the persona in the actual store
- public weak Persona real_persona { get; set; default = null; }
-
- private string[] _writeable_properties = {};
- private const string[] _linkable_properties = {};
- public override string[] linkable_properties {
- get { return _linkable_properties; }
- }
-
- public override string[] writeable_properties {
- get {
- return this._writeable_properties;
- }
- }
-
- private Gee.ArrayList<string> _changed_properties;
-
- construct {
- this._changed_properties = new Gee.ArrayList<string> ();
- }
-
- public LoadableIcon? avatar {
- get {
- unowned Value? value = this.properties.get ("avatar");
- // Casting a Value internally makes it use g_value_get_object(),
- // which is of course not allowed for a NULL value
- return (value != null)? (LoadableIcon?) value : null;
- }
- set {
- this.properties.set ("avatar", value);
- }
- }
-
- public async void change_avatar (LoadableIcon? avatar) throws PropertyError {
- this.avatar = avatar;
- }
-
- public string full_name {
- get {
- unowned Value? value = this.properties.get ("full-name");
- if (value == null)
- return "";
- return value.get_string ();
- }
- set {
- this.properties.set ("full-name", value);
- }
- }
-
- public string nickname {
- get {
- unowned Value? value = this.properties.get ("nickname");
- if (value == null)
- return "";
- return value.get_string ();
- }
- set {
- this.properties.set ("nickname", value);
- }
- }
-
- //TODO: implement structured_name
- public StructuredName? structured_name {
- get { return null; }
- set {}
- }
-
- public Gee.Set<PhoneFieldDetails> phone_numbers {
- get {
- unowned Value? value = this.properties.get ("phone-numbers");
- if (value == null) {
- var new_value = GLib.Value (typeof (Gee.Set));
- var set = new FakeHashSet<PhoneFieldDetails> ();
- new_value.set_object (set);
- set.changed.connect (() => { notify_property ("phone-numbers"); });
- this.properties.set ("phone-numbers", new_value);
- value = this.properties.get ("phone-numbers");
- }
- return (Gee.Set<PhoneFieldDetails>) value;
- }
- set {
- this.properties.set ("phone-numbers", value);
- }
- }
-
- public Gee.Set<UrlFieldDetails> urls {
- get {
- unowned Value? value = this.properties.get ("urls");
- if (value == null) {
- var new_value = Value (typeof (Gee.Set));
- var set = new FakeHashSet<UrlFieldDetails> ();
- new_value.set_object (set);
- set.changed.connect (() => { notify_property ("urls"); });
- this.properties.set ("urls", new_value);
- value = new_value;
- }
- return (Gee.Set<UrlFieldDetails>) value;
- }
- set {
- this.properties.set ("urls", value);
- }
- }
-
- public Gee.Set<PostalAddressFieldDetails> postal_addresses {
- get {
- unowned Value? value = this.properties.get ("postal-addresses");
- if (value == null) {
- var new_value = Value (typeof (Gee.Set));
- var set = new FakeHashSet<PostalAddressFieldDetails> ();
- new_value.set_object (set);
- set.changed.connect (() => { notify_property ("postal-addresses"); });
- this.properties.set ("postal-addresses", new_value);
- value = new_value;
- }
- return (Gee.Set<PostalAddressFieldDetails>) value;
- }
- set {
- this.properties.set ("postal-addresses", value);
- }
- }
-
- public Gee.Set<NoteFieldDetails> notes {
- get {
- unowned Value? value = this.properties.get ("notes");
- if (value == null) {
- var new_value = Value (typeof (Gee.Set));
- var set = new FakeHashSet<NoteFieldDetails> ();
- new_value.set_object (set);
- set.changed.connect (() => { notify_property ("notes"); });
- this.properties.set ("notes", new_value);
- value = new_value;
- }
- return (Gee.Set<NoteFieldDetails>) value;
- }
- set {
- this.properties.set ("notes", value);
- }
- }
-
- public Gee.Set<RoleFieldDetails> roles {
- get {
- unowned Value? value = this.properties.get ("roles");
- if (value == null) {
- var new_value = Value (typeof (Gee.Set));
- var set = new FakeHashSet<RoleFieldDetails> ();
- new_value.set_object (set);
- set.changed.connect (() => { notify_property ("roles"); });
- this.properties.set ("roles", new_value);
- value = new_value;
- }
- return (Gee.Set<RoleFieldDetails>) value;
- }
- set {
- this.properties.set ("roles", value);
- }
- }
-
- public DateTime? birthday {
- get { unowned Value? value = this.properties.get ("birthday");
- if (value == null)
- return null;
- return (DateTime) value;
- }
- set {
- this.properties.set ("birthday", value);
- }
- }
-
- //TODO implement calender_event_id
- public string? calendar_event_id {
- get { return null; }
- set {}
- }
-
- public Gee.MultiMap<string,ImFieldDetails> im_addresses {
- get {
- unowned Value? value = this.properties.get ("im-addresses");
- if (value == null) {
- var new_value = Value (typeof (Gee.MultiMap));
- var set = new FakeHashMultiMap<string, ImFieldDetails> ();
- new_value.set_object (set);
- this.properties.set ("im-addresses", new_value);
- set.changed.connect (() => { notify_property ("im-addresses"); });
- value = new_value;
- }
- return (Gee.MultiMap<string, ImFieldDetails>) value;
- }
- set {
- this.properties.set ("im-addresses", value);
- }
- }
-
- public Gee.Set<EmailFieldDetails> email_addresses {
- get {
- unowned Value? value = this.properties.get ("email-addresses");
- if (value == null) {
- var new_value = Value (typeof (Gee.Set));
- var set = new FakeHashSet<EmailFieldDetails> ();
- set.changed.connect (() => { notify_property ("email-addresses"); });
- new_value.set_object (set);
- this.properties.set ("email-addresses", new_value);
- value = new_value;
- }
- return (Gee.Set<EmailFieldDetails>) value;
- }
- set {
- this.properties.set ("email-addresses", value);
- }
- }
-
- public FakePersona (PersonaStore store, string[] writeable_properties, HashTable<string, Value?> details) {
- var id = Uuid.string_random();
- var uid = Folks.Persona.build_uid (BACKEND_NAME, store.id, id);
- var iid = "%s:%s".printf (store.id, id);
- Object (display_id: iid,
- uid: uid,
- iid: iid,
- store: store,
- is_user: false);
-
- this.properties = details;
- this._writeable_properties = writeable_properties;
- }
-
- public FakePersona.from_real (Persona persona) {
- var details = new HashTable<string, Value?> (str_hash, str_equal);
- this (FakePersonaStore.the_store (), persona.writeable_properties, details);
- // FIXME: get all properties not only writable properties
- var props = persona.writeable_properties;
- foreach (var prop in props) {
- get_property_from_real (persona, prop);
- }
-
- this.real_persona = persona;
- // FIXME: we are adding property changes also for things we don't care about e.g. individual
- this.notify.connect((obj, ps) => {
- add_to_changed_properties(ps.name);
- });
- }
-
- private void get_property_from_real (Persona persona, string property_name) {
- // TODO Implement the interface for the commented properties
- switch (property_name) {
- case "alias":
- //alias = ((AliasDetails) persona).alias;
- break;
- case "avatar":
- avatar = ((AvatarDetails) persona).avatar;
- break;
- case "birthday":
- birthday = ((BirthdayDetails) persona).birthday;
- break;
- case "calendar-event-id":
- calendar_event_id = ((BirthdayDetails) persona).calendar_event_id;
- break;
- case "email-addresses":
- foreach (var e in ((EmailDetails) persona).email_addresses) {
- email_addresses.add (new EmailFieldDetails (e.value, e.parameters));
- }
- break;
- case "is-favourite":
- //is_favourite = ((FavouriteDetails) persona).is_favourite;
- break;
- case "gender":
- //gender = ((GenderDetails) persona).gender;
- break;
- case "groups":
- //groups = ((GroupDetails) persona).groups;
- break;
- case "im-addresses":
- im_addresses = ((ImDetails) persona).im_addresses;
- break;
- case "local-ids":
- //local_ids = ((LocalIdDetails) persona).local_ids;
- break;
- case "structured-name":
- structured_name = ((NameDetails) persona).structured_name;
- break;
- case "full-name":
- full_name = ((NameDetails) persona).full_name;
- break;
- case "nickname":
- nickname = ((NameDetails) persona).nickname;
- break;
- case "notes":
- foreach (var e in ((NoteDetails) persona).notes) {
- notes.add (new NoteFieldDetails (e.value, e.parameters, e.id));
- }
- break;
- case "phone-numbers":
- foreach (var e in ((PhoneDetails) persona).phone_numbers) {
- phone_numbers.add (new PhoneFieldDetails (e.value, e.parameters));
- }
- break;
- case "postal-addresses":
- foreach (var e in ((PostalAddressDetails) persona).postal_addresses) {
- postal_addresses.add (new PostalAddressFieldDetails (e.value, e.parameters));
- }
- break;
- case "roles":
- foreach (var role in ((RoleDetails) persona).roles) {
- this.roles.add (new RoleFieldDetails (role.value, role.parameters));
- }
- break;
- case "urls":
- foreach (var e in ((UrlDetails) persona).urls) {
- urls.add (new UrlFieldDetails (e.value, e.parameters));
- }
- break;
- case "web-service-addresses":
- //web_service_addresses.add_all(((WebServiceDetails) persona).web_service_addresses);
- break;
- default:
- debug ("Unknown property '%s' in FakePersona.get_property_from_real().", property_name);
- break;
- }
- }
-
- private void add_to_changed_properties (string property_name) {
- debug ("Property: %s was added to the changed property list", property_name);
- if (!this._changed_properties.contains(property_name))
- this._changed_properties.add (property_name);
- }
-
- public HashTable<string, Value?> get_details () {
- return this.properties;
- }
-
- public async void apply_changes_to_real () {
- if (this.real_persona == null) {
- warning ("No real persona to apply changes from fake persona");
- return;
- }
- foreach (var prop in _changed_properties) {
- if (properties.contains (prop)) {
- try {
- yield set_persona_property (this.real_persona, prop, properties.get (prop));
- } catch (Error e) {
- error ("Couldn't write property: %s", e.message);
- }
- }
- }
- }
-
- private static async void set_persona_property (Persona persona,
- string property_name, Value new_value) throws PropertyError, IndividualAggregatorError, PropertyError {
- switch (property_name) {
- case "alias":
- yield ((AliasDetails) persona).change_alias ((string) new_value);
- break;
- case "avatar":
- yield ((AvatarDetails) persona).change_avatar ((LoadableIcon?) new_value);
- break;
- case "birthday":
- yield ((BirthdayDetails) persona).change_birthday ((DateTime?) new_value);
- break;
- case "calendar-event-id":
- yield ((BirthdayDetails) persona).change_calendar_event_id ((string?) new_value);
- break;
- case "email-addresses":
- var original = (Gee.Set<EmailFieldDetails>) new_value;
- var copy = new Gee.HashSet<EmailFieldDetails> ();
- foreach (var e in original) {
- if (e.value != null && e.value != "")
- copy.add (new EmailFieldDetails (e.value, e.parameters));
- }
- yield ((EmailDetails) persona).change_email_addresses (copy);
- break;
- case "is-favourite":
- yield ((FavouriteDetails) persona).change_is_favourite ((bool) new_value);
- break;
- case "gender":
- yield ((GenderDetails) persona).change_gender ((Gender) new_value);
- break;
- case "groups":
- yield ((GroupDetails) persona).change_groups ((Gee.Set<string>) new_value);
- break;
- case "im-addresses":
- yield ((ImDetails) persona).change_im_addresses ((Gee.MultiMap<string, ImFieldDetails>) new_value);
- break;
- case "local-ids":
- yield ((LocalIdDetails) persona).change_local_ids ((Gee.Set<string>) new_value);
- break;
- case "structured-name":
- yield ((NameDetails) persona).change_structured_name ((StructuredName?) new_value);
- break;
- case "full-name":
- yield ((NameDetails) persona).change_full_name ((string) new_value);
- break;
- case "nickname":
- yield ((NameDetails) persona).change_nickname ((string) new_value);
- break;
- case "notes":
- var original = (Gee.Set<NoteFieldDetails>) new_value;
- var copy = new Gee.HashSet<NoteFieldDetails> ();
- foreach (var e in original) {
- if (e.value != null && e.value != "")
- copy.add (new NoteFieldDetails (e.value, e.parameters));
- }
- yield ((NoteDetails) persona).change_notes (copy);
- break;
- case "phone-numbers":
- var original = (Gee.Set<PhoneFieldDetails>) new_value;
- var copy = new Gee.HashSet<PhoneFieldDetails> ();
- foreach (var e in original) {
- if (e.value != null && e.value != "")
- copy.add (new PhoneFieldDetails (e.value, e.parameters));
- }
- yield ((PhoneDetails) persona).change_phone_numbers (copy);
- break;
- case "postal-addresses":
- var original = (Gee.Set<PostalAddressFieldDetails>) new_value;
- var copy = new Gee.HashSet<PostalAddressFieldDetails> ();
- foreach (var e in original) {
- if (e.value != null && !e.value.is_empty ())
- copy.add (new PostalAddressFieldDetails (e.value, e.parameters));
- }
- yield ((PostalAddressDetails) persona).change_postal_addresses (copy);
- break;
- case "roles":
- var original = (Gee.Set<RoleFieldDetails>) new_value;
- var copy = new Gee.HashSet<RoleFieldDetails> ();
- foreach (var e in original) {
- if (e.value != null && !e.value.is_empty ())
- copy.add (new RoleFieldDetails (e.value, e.parameters));
- }
- yield ((RoleDetails) persona).change_roles (copy);
- break;
- case "urls":
- var original = (Gee.Set<UrlFieldDetails>) new_value;
- var copy = new Gee.HashSet<UrlFieldDetails> ();
- foreach (var e in original) {
- if (e.value != null && e.value != "")
- copy.add (new UrlFieldDetails (e.value, e.parameters));
- }
- yield ((UrlDetails) persona).change_urls (copy);
- break;
- case "web-service-addresses":
- yield ((WebServiceDetails) persona).change_web_service_addresses ((Gee.MultiMap<string, WebServiceFieldDetails>) new_value);
- break;
- default:
- critical ("Unknown property '%s' in Contact.set_persona_property().", property_name);
- break;
- }
- }
-}
-
-/**
- * A FakeIndividual
- */
-public class Contacts.FakeIndividual : Individual {
- public weak Individual real_individual { get; set; default = null; }
- public weak FakePersona primary_persona { get; set; default = null; }
- public FakeIndividual (Gee.Set<FakePersona>? personas) {
- base (personas);
- foreach (var p in personas) {
- // Keep track of the main persona
- if (Contacts.Utils.persona_is_main (p) || personas.size == 1)
- primary_persona = p;
- }
- }
-
- public FakeIndividual.from_real (Individual individual) {
- var fake_personas = new Gee.HashSet<FakePersona> ();
- foreach (var p in individual.personas) {
- var fake_p = new FakePersona.from_real (p);
- fake_personas.add (fake_p);
- }
- this (fake_personas);
- this.real_individual = individual;
- }
-
- public async void apply_changes_to_real () {
- if (this.real_individual == null) {
- warning ("No real individual to apply changes from fake individual");
- return;
- }
-
- foreach (var p in this.personas) {
- var fake_persona = p as FakePersona;
- if (fake_persona != null) {
- yield fake_persona.apply_changes_to_real ();
- }
- }
- }
-}
-
-/**
- * This is the same as Gee.HashSet but adds a changed/added/removed signals
- */
-public class Contacts.FakeHashSet<T> : Gee.HashSet<T> {
- public signal void changed ();
- public signal void added ();
- public signal void removed ();
-
- public FakeHashSet () {
- base ();
- }
-
- public override bool add (T element) {
- var res = base.add (element);
- if (res) {
- added ();
- changed ();
- }
- return res;
- }
-
- public override bool remove (T element) {
- var res = base.remove (element);
- if (res) {
- removed();
- changed ();
- }
- return res;
- }
-}
-
-/**
- * This is the same as Gee.HashMultiMap but adds a changed signal
- */
-public class Contacts.FakeHashMultiMap<K, T> : Gee.HashMultiMap<K, T> {
- public signal void changed ();
-
- public FakeHashMultiMap () {
- base ();
- }
-}
diff --git a/src/contacts-main-window.vala b/src/contacts-main-window.vala
index f6f465c..e2fbeec 100644
--- a/src/contacts-main-window.vala
+++ b/src/contacts-main-window.vala
@@ -443,7 +443,7 @@ public class Contacts.MainWindow : Adw.ApplicationWindow {
// clearing right_header
this.right_header.title_widget = new Adw.WindowTitle ("", "");
- if (selected == null) {
+ if (selected != null) {
this.ignore_favorite_button_toggled = true;
this.favorite_button.active = selected.is_favourite;
this.ignore_favorite_button_toggled = false;
diff --git a/src/contacts-persona-sorter.vala b/src/contacts-persona-sorter.vala
index 4ba4006..ff0aeaf 100644
--- a/src/contacts-persona-sorter.vala
+++ b/src/contacts-persona-sorter.vala
@@ -30,24 +30,34 @@ public class Contacts.PersonaSorter : Gtk.Sorter {
public override Gtk.Ordering compare (Object? item1, Object? item2) {
unowned var persona_1 = (Persona) item1;
unowned var persona_2 = (Persona) item2;
+
+ if (persona_1 == persona_2)
+ return Gtk.Ordering.EQUAL;
+
+ // Put null persona's last
+ if (persona_1 == null || persona_2 == null)
+ return (persona_1 == null)? Gtk.Ordering.LARGER : Gtk.Ordering.SMALLER;
+
unowned var store_1 = persona_1.store;
unowned var store_2 = persona_2.store;
// In the same store, sort Google 'other' contacts last
if (store_1 == store_2) {
- if (!Utils.persona_is_google (persona_1))
- return Gtk.Ordering.EQUAL;
+ if (Utils.persona_is_google (persona_1)) {
+ var p1_is_other = Utils.persona_is_google_other (persona_1);
+ if (p1_is_other != Utils.persona_is_google_other (persona_2))
+ return p1_is_other? Gtk.Ordering.LARGER : Gtk.Ordering.SMALLER;
+ }
- var p1_is_other = Utils.persona_is_google_other (persona_1);
- if (p1_is_other != Utils.persona_is_google_other (persona_2))
- return p1_is_other? Gtk.Ordering.LARGER : Gtk.Ordering.SMALLER;
+ // Sort on Persona UIDs so we get a consistent sort
+ return Gtk.Ordering.from_cmpfunc (strcmp (persona_1.uid, persona_2.uid));
}
// Sort primary stores before others
if (store_1.is_primary_store != store_2.is_primary_store)
return (store_1.is_primary_store)? Gtk.Ordering.SMALLER : Gtk.Ordering.LARGER;
- // E-D-S stores get prioritized
+ // E-D-S stores get prioritized next
if ((store_1.type_id == "eds") != (store_2.type_id == "eds"))
return (store_1.type_id == "eds")? Gtk.Ordering.SMALLER : Gtk.Ordering.LARGER;
diff --git a/src/contacts-type-combo.vala b/src/contacts-type-combo.vala
index 3f252f7..2940a5e 100644
--- a/src/contacts-type-combo.vala
+++ b/src/contacts-type-combo.vala
@@ -61,4 +61,14 @@ public class Contacts.TypeComboRow : Adw.ComboRow {
this.type_set.lookup_by_vcard_type (type, out position);
this.selected = position;
}
+
+ /**
+ * Sets the value to the type that best matches the given vcard type
+ * (for example "HOME" or "WORK").
+ */
+ public void set_selected_from_parameters (Gee.MultiMap<string, string> parameters) {
+ uint position = 0;
+ this.type_set.lookup_by_parameters (parameters, out position);
+ this.selected = position;
+ }
}
diff --git a/src/contacts-type-descriptor.vala b/src/contacts-type-descriptor.vala
index 8335f56..c4e4dc0 100644
--- a/src/contacts-type-descriptor.vala
+++ b/src/contacts-type-descriptor.vala
@@ -88,14 +88,16 @@ public class Contacts.TypeDescriptor : Object {
}
public void save_to_field_details (AbstractFieldDetails details) {
- debug ("Saving type %s", to_string ());
+ debug ("Saving type %s to AbsractFieldDetails", to_string ());
+ details.parameters = adapt_parameters (details.parameters);
+ }
- var old_parameters = details.parameters;
- var new_parameters = new Gee.HashMultiMap<string, string> ();
+ public Gee.MultiMap<string, string> adapt_parameters (Gee.MultiMap<string, string> parameters) {
+ var result = new Gee.HashMultiMap<string, string> ();
// Check whether PREF VCard "flag" is set
bool has_pref = false;
- foreach (var val in old_parameters["type"]) {
+ foreach (var val in parameters["type"]) {
if (val.ascii_casecmp ("PREF") == 0) {
has_pref = true;
break;
@@ -103,10 +105,10 @@ public class Contacts.TypeDescriptor : Object {
}
// Copy over all parameters, execept the ones we're going to create ourselves
- foreach (var param in old_parameters.get_keys ()) {
+ foreach (var param in parameters.get_keys ()) {
if (param != "type" && param != X_GOOGLE_LABEL)
- foreach (var val in old_parameters[param])
- new_parameters[param] = val;
+ foreach (var val in parameters[param])
+ result[param] = val;
}
// Set the type based on our Source
@@ -114,22 +116,21 @@ public class Contacts.TypeDescriptor : Object {
case Source.VCARD:
foreach (var type in this.vcard_types)
if (type != null)
- new_parameters["type"] = type;
+ result["type"] = type;
break;
case Source.OTHER:
- new_parameters["type"] = "OTHER";
+ result["type"] = "OTHER";
break;
case Source.CUSTOM:
- new_parameters["type"] = "OTHER";
- new_parameters[X_GOOGLE_LABEL] = this.name;
+ result["type"] = "OTHER";
+ result[X_GOOGLE_LABEL] = this.name;
break;
}
if (has_pref)
- new_parameters["type"] = "PREF";
+ result["type"] = "PREF";
- // We didn't crash 'n burn, so lets
- details.parameters = new_parameters;
+ return result;
}
/**
diff --git a/src/contacts-typeset.vala b/src/contacts-typeset.vala
index 733de79..8e46299 100644
--- a/src/contacts-typeset.vala
+++ b/src/contacts-typeset.vala
@@ -143,8 +143,17 @@ public class Contacts.TypeSet : Object, GLib.ListModel {
*/
public TypeDescriptor lookup_by_field_details (AbstractFieldDetails detail,
out uint position = null) {
- if (detail.parameters.contains (TypeDescriptor.X_GOOGLE_LABEL)) {
- var label = Utils.get_first<string> (detail.parameters[TypeDescriptor.X_GOOGLE_LABEL]);
+ return lookup_by_parameters (detail.parameters, out position);
+ }
+
+ /**
+ * Looks up the TypeDescriptor for the given parameters. If the descriptor
+ * is not found, it will be created and returned, so this never returns null.
+ */
+ public TypeDescriptor lookup_by_parameters (Gee.MultiMap<string, string> parameters,
+ out uint position = null) {
+ if (parameters.contains (TypeDescriptor.X_GOOGLE_LABEL)) {
+ var label = Utils.get_first<string> (parameters[TypeDescriptor.X_GOOGLE_LABEL]);
var descriptor = lookup_by_custom_label (label, out position);
// Still didn't find it => create it
if (descriptor == null)
@@ -152,7 +161,7 @@ public class Contacts.TypeSet : Object, GLib.ListModel {
return descriptor;
}
- var types = detail.get_parameter_values ("type");
+ var types = parameters["type"];
if (types == null || types.is_empty) {
debug ("No types given in the AbstractFieldDetails");
return this.other_dummy;
diff --git a/src/contacts-utils.vala b/src/contacts-utils.vala
index 4bfd810..7edaa7c 100644
--- a/src/contacts-utils.vala
+++ b/src/contacts-utils.vala
@@ -18,10 +18,6 @@
using Folks;
namespace Contacts {
- public bool is_set (string? str) {
- return str != null && str != "";
- }
-
public void add_separator (Gtk.ListBoxRow row, Gtk.ListBoxRow? before_row) {
row.set_header (new Gtk.Separator (Gtk.Orientation.HORIZONTAL));
}
@@ -35,11 +31,6 @@ namespace Contacts.Utils {
settings.set_string ("primary-store", "eds:%s".printf (e_store.id));
}
- public void compose_mail (string email) {
- var mailto_uri = "mailto:" + Uri.escape_string (email, "@" , false);
- Gtk.show_uri (null, mailto_uri, 0);
- }
-
public T? get_first<T> (Gee.Collection<T> collection) {
var i = collection.iterator();
if (i.next())
@@ -141,49 +132,6 @@ namespace Contacts.Utils {
return new Gtk.SortListModel ((owned) res, new AbstractFieldDetailsSorter ());
}
- public string[] format_address (PostalAddress addr) {
- string[] lines = {};
-
- if (is_set (addr.street))
- lines += addr.street;
-
- if (is_set (addr.extension))
- lines += addr.extension;
-
- if (is_set (addr.locality))
- lines += addr.locality;
-
- if (is_set (addr.region))
- lines += addr.region;
-
- if (is_set (addr.postal_code))
- lines += addr.postal_code;
-
- if (is_set (addr.po_box))
- lines += addr.po_box;
-
- if (is_set (addr.country))
- lines += addr.country;
-
- if (is_set (addr.address_format))
- lines += addr.address_format;
-
- return lines;
- }
-
- /**
- * Takes an individual's postal address and creates a "maps:q=..." URI for
- * it, which can be launched to use the local system's maps handler
- * (e.g. GNOME Maps).
- *
- * See also https://www.iana.org/assignments/uri-schemes/prov/maps for the
- * "specification"
- */
- public string create_maps_uri (PostalAddress address) {
- var address_parts = string.joinv (" ", Utils.format_address (address));
- return "maps:q=%s".printf (GLib.Uri.escape_string (address_parts));
- }
-
/* We claim something is "removable" if at least one persona is removable,
that will typically unlink the rest. */
public bool can_remove_personas (Individual individual) {
@@ -201,22 +149,6 @@ namespace Contacts.Utils {
return personas;
}
- public Persona? find_primary_persona (Individual individual) {
- foreach (var p in individual.personas)
- if (p.store.is_primary_store)
- return p;
-
- return null;
- }
-
- public Persona? find_persona_from_uid (Individual individual, string uid) {
- foreach (var p in individual.personas) {
- if (p.uid == uid)
- return p;
- }
- return null;
- }
-
public string format_persona_stores (Individual individual) {
string stores = "";
bool first = true;
@@ -297,85 +229,6 @@ namespace Contacts.Utils {
return store.display_name;
}
- /* Tries to set the property on all persons that have it writeable */
- public async void set_individual_property (Individual individual, string property_name, Value value)
- throws GLib.Error, PropertyError {
- // Need to make a copy here as it could change during the yields
- var personas_copy = individual.personas.to_array ();
- foreach (var p in personas_copy) {
- if (property_name in p.writeable_properties) {
- yield set_persona_property (p, property_name, value);
- }
- }
- //TODO: Add fallback if we can't write to any persona (Do we want to support that?)
- }
-
- public async void set_persona_property (Persona persona,
- string property_name, Value new_value) throws PropertyError, IndividualAggregatorError {
- switch (property_name) {
- case "alias":
- yield ((AliasDetails) persona).change_alias ((string) new_value);
- break;
- case "avatar":
- yield ((AvatarDetails) persona).change_avatar ((LoadableIcon?) new_value);
- break;
- case "birthday":
- yield ((BirthdayDetails) persona).change_birthday ((DateTime?) new_value);
- break;
- case "calendar-event-id":
- yield ((BirthdayDetails) persona).change_calendar_event_id ((string?) new_value);
- break;
- case "email-addresses":
- yield ((EmailDetails) persona).change_email_addresses ((Gee.Set<EmailFieldDetails>) new_value);
- break;
- case "is-favourite":
- yield ((FavouriteDetails) persona).change_is_favourite ((bool) new_value);
- break;
- case "gender":
- yield ((GenderDetails) persona).change_gender ((Gender) new_value);
- break;
- case "groups":
- yield ((GroupDetails) persona).change_groups ((Gee.Set<string>) new_value);
- break;
- case "im-addresses":
- yield ((ImDetails) persona).change_im_addresses ((Gee.MultiMap<string, ImFieldDetails>) new_value);
- break;
- case "local-ids":
- yield ((LocalIdDetails) persona).change_local_ids ((Gee.Set<string>) new_value);
- break;
- case "structured-name":
- yield ((NameDetails) persona).change_structured_name ((StructuredName?) new_value);
- break;
- case "full-name":
- yield ((NameDetails) persona).change_full_name ((string) new_value);
- break;
- case "nickname":
- yield ((NameDetails) persona).change_nickname ((string) new_value);
- break;
- case "notes":
- yield ((NoteDetails) persona).change_notes ((Gee.Set<NoteFieldDetails>) new_value);
- break;
- case "phone-numbers":
- yield ((PhoneDetails) persona).change_phone_numbers ((Gee.Set<PhoneFieldDetails>) new_value);
- break;
- case "postal-addresses":
- yield ((PostalAddressDetails) persona).change_postal_addresses ((Gee.Set<PostalAddressFieldDetails>) new_value);
- break;
- case "roles":
- yield ((RoleDetails) persona).change_roles ((Gee.Set<RoleFieldDetails>) new_value);
- break;
- case "urls":
- yield ((UrlDetails) persona).change_urls ((Gee.Set<UrlFieldDetails>) new_value);
- break;
- case "web-service-addresses":
- yield ((WebServiceDetails) persona).change_web_service_addresses ((Gee.MultiMap<string, WebServiceFieldDetails>) new_value);
- break;
- default:
- critical ("Unknown property '%s' in Contact.set_persona_property().", property_name);
- break;
- }
- }
-
// A helper struct to keep track on general properties on how each Persona
// property should be displayed
private struct PropertyDisplayInfo {
diff --git a/src/core/contacts-addresses-chunk.vala b/src/core/contacts-addresses-chunk.vala
new file mode 100644
index 0000000..c322e91
--- /dev/null
+++ b/src/core/contacts-addresses-chunk.vala
@@ -0,0 +1,138 @@
+/*
+ * Copyright (C) 2022 Niels De Graef <nielsdegraef@gmail.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+using Folks;
+
+/**
+ * A {@link Chunk} that represents the postal addresses of a contact (similar
+ * to {@link Folks.PostalAddressDetails}}. Each element is a {@link Address}.
+ */
+public class Contacts.AddressesChunk : BinChunk {
+
+ public override string property_name { get { return "postal-addresses"; } }
+
+ construct {
+ if (persona != null) {
+ return_if_fail (persona is PostalAddressDetails);
+ unowned var postal_address_details = (PostalAddressDetails) persona;
+
+ foreach (var address_field in postal_address_details.postal_addresses) {
+ var address = new Address.from_field_details (address_field);
+ add_child (address);
+ }
+ }
+
+ emptiness_check ();
+ }
+
+ protected override BinChunkChild create_empty_child () {
+ return new Address ();
+ }
+
+ public override async void save_to_persona () throws GLib.Error
+ requires (this.persona is PostalAddressDetails) {
+ var afds = (Gee.Set<PostalAddressFieldDetails>) get_abstract_field_details ();
+ yield ((PostalAddressDetails) this.persona).change_postal_addresses (afds);
+ }
+}
+
+public class Contacts.Address : BinChunkChild {
+
+ public PostalAddress address {
+ get { return this._address; }
+ set {
+ if (this._address.equal (value))
+ return;
+
+ bool was_empty = this._address.is_empty ();
+ this._address = value;
+ notify_property ("address");
+ if (was_empty != value.is_empty ())
+ notify_property ("is-empty");
+ }
+ }
+ private PostalAddress _address = new PostalAddress ("", "", "", "", "", "", "", "", "");
+
+ public override bool is_empty {
+ get { return this.address.is_empty (); }
+ }
+
+ public override string icon_name {
+ get { return "mark-location-symbolic"; }
+ }
+
+ public Address () {
+ this.parameters = new Gee.HashMultiMap<string, string> ();
+ this.parameters["type"] = "HOME";
+ }
+
+ public Address.from_field_details (PostalAddressFieldDetails address_field) {
+ this.address = address_field.value;
+ this.parameters = address_field.parameters;
+ }
+
+ /**
+ * Returns the TypeDescriptor that describes the type of this address
+ * (for example home, work, ...)
+ */
+ public TypeDescriptor get_address_type () {
+ return TypeSet.general.lookup_by_parameters (this.parameters);
+ }
+
+ /**
+ * Returns the address as a single string, with the several parts of
+ * the address joined together with @parts_separator.
+ */
+ public string to_string (string parts_separator) {
+ string[] lines = {};
+
+ if (this.address.street != "")
+ lines += this.address.street;
+ if (this.address.extension != "")
+ lines += this.address.extension;
+ if (this.address.locality != "")
+ lines += this.address.locality;
+ if (this.address.region != "")
+ lines += this.address.region;
+ if (this.address.postal_code != "")
+ lines += this.address.postal_code;
+ if (this.address.po_box != "")
+ lines += this.address.po_box;
+ if (this.address.country != "")
+ lines += this.address.country;
+ if (this.address.address_format != "")
+ lines += this.address.address_format;
+
+ return string.joinv (parts_separator, lines);
+ }
+
+ /**
+ * Returns the address as a "maps:q=..." URI, which can then be used
+ * by supported apps to open up the specified location.
+ */
+ public string to_maps_uri () {
+ var address_parts = to_string (" ");
+ return "maps:q=%s".printf (GLib.Uri.escape_string (address_parts));
+ }
+
+ public override AbstractFieldDetails? create_afd () {
+ if (this.is_empty)
+ return null;
+
+ return new PostalAddressFieldDetails (this.address, this.parameters);
+ }
+}
diff --git a/src/core/contacts-alias-chunk.vala b/src/core/contacts-alias-chunk.vala
new file mode 100644
index 0000000..921e9cf
--- /dev/null
+++ b/src/core/contacts-alias-chunk.vala
@@ -0,0 +1,57 @@
+/*
+ * Copyright (C) 2022 Niels De Graef <nielsdegraef@gmail.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+using Folks;
+
+public class Contacts.AliasChunk : Chunk {
+
+ public string alias {
+ get { return this._alias; }
+ set {
+ if (this._alias == value)
+ return;
+
+ bool was_empty = this.is_empty;
+ this._alias = value;
+ notify_property ("alias");
+ if (this.is_empty != was_empty)
+ notify_property ("is-empty");
+ }
+ }
+ private string _alias = "";
+
+ public override string property_name { get { return "alias"; } }
+
+ public override bool is_empty { get { return this._alias.strip () == ""; } }
+
+ construct {
+ if (persona != null) {
+ return_if_fail (persona is AliasDetails);
+ persona.bind_property ("alias", this, "alias", BindingFlags.SYNC_CREATE);
+ }
+ }
+
+ public override Value? to_value () {
+ return this.alias;
+ }
+
+ public override async void save_to_persona () throws GLib.Error
+ requires (this.persona is AliasDetails) {
+
+ yield ((AliasDetails) this.persona).change_alias (this.alias);
+ }
+}
diff --git a/src/core/contacts-avatar-chunk.vala b/src/core/contacts-avatar-chunk.vala
new file mode 100644
index 0000000..56dbce2
--- /dev/null
+++ b/src/core/contacts-avatar-chunk.vala
@@ -0,0 +1,53 @@
+/*
+ * Copyright (C) 2022 Niels De Graef <nielsdegraef@gmail.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+using Folks;
+
+public class Contacts.AvatarChunk : Chunk {
+
+ public LoadableIcon? avatar {
+ get { return this._avatar; }
+ set {
+ if (this._avatar == value)
+ return;
+ this._avatar = value;
+ notify_property ("avatar");
+ notify_property ("is-empty");
+ }
+ }
+ private LoadableIcon? _avatar = null;
+
+ public override string property_name { get { return "avatar"; } }
+
+ public override bool is_empty { get { return this._avatar == null; } }
+
+ construct {
+ if (persona != null) {
+ return_if_fail (persona is AvatarDetails);
+ persona.bind_property ("avatar", this, "avatar", BindingFlags.SYNC_CREATE);
+ }
+ }
+
+ public override Value? to_value () {
+ return this._avatar;
+ }
+
+ public override async void save_to_persona () throws GLib.Error
+ requires (this.persona is AvatarDetails) {
+ yield ((AvatarDetails) this.persona).change_avatar (this.avatar);
+ }
+}
diff --git a/src/core/contacts-bin-chunk.vala b/src/core/contacts-bin-chunk.vala
new file mode 100644
index 0000000..3229ddd
--- /dev/null
+++ b/src/core/contacts-bin-chunk.vala
@@ -0,0 +1,171 @@
+/*
+ * Copyright (C) 2022 Niels De Graef <nielsdegraef@gmail.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+using Folks;
+
+/**
+ * A {@link Chunk} that aggregates multiple values associated to a property
+ * (for example, a chunk for phone numbers, or email addresses). These values
+ * are represented as {@link BinChunkChild}ren, which BinChunk exposes through
+ * the {@link GLib.ListModel} interface.
+ *
+ * One important property of BinkChunk is that it makes sure at least one empty
+ * child exists. This allows us to expose an immutable interface, while being
+ * able to synchronize with our UI (which expects this kind of behavior)
+ */
+public abstract class Contacts.BinChunk : Chunk, GLib.ListModel {
+
+ private GenericArray<BinChunkChild> elements = new GenericArray<BinChunkChild> ();
+
+ public override bool is_empty {
+ get {
+ if (this.elements.length == 0)
+ return true;
+ foreach (var chunk_element in this.elements) {
+ if (!chunk_element.is_empty)
+ return false;
+ }
+ return true;
+ }
+ }
+
+ /**
+ * Should be called by subclasses when they add a child.
+ * It will make sure to attach the emptines check is appropriately applied.
+ */
+ protected void add_child (BinChunkChild child) {
+ if (child.is_empty && has_empty_child ())
+ return;
+
+ child.notify["is-empty"].connect ((obj, pspec) => {
+ debug ("Child 'is-empty' changed, doing emptiness check");
+ emptiness_check ();
+ });
+ this.elements.add (child);
+ items_changed (this.elements.length - 1, 0, 1);
+ }
+
+ /**
+ * Subclasses should implement this to create an empty child (which will be
+ * used for the emptiness check).
+ */
+ protected abstract BinChunkChild create_empty_child ();
+
+ // A method to check if we have at least one empty row
+ // if we don't, it adds an empty child
+ protected void emptiness_check () {
+ if (has_empty_child ())
+ return;
+
+ // We only have non-empty rows, add one
+ var child = create_empty_child ();
+ add_child (child);
+ }
+
+ private bool has_empty_child () {
+ for (uint i = 0; i < this.elements.length; i++) {
+ if (this.elements[i].is_empty)
+ return true;
+ }
+ return false;
+ }
+
+ public override Value? to_value () {
+ var afds = new Gee.HashSet<AbstractFieldDetails> ();
+ for (uint i = 0; i < this.elements.length; i++) {
+ var afd = this.elements[i].create_afd ();
+ if (afd != null)
+ afds.add (afd);
+ }
+ return (afds.size != 0)? afds : null;
+ }
+
+ /** A helper function to collect the AbstractFieldDetails of the children */
+ protected Gee.Set<AbstractFieldDetails> get_abstract_field_details ()
+ requires (this.persona != null) {
+ var afds = new Gee.HashSet<AbstractFieldDetails> ();
+ for (uint i = 0; i < this.elements.length; i++) {
+ var afd = this.elements[i].create_afd ();
+ if (afd != null)
+ afds.add (afd);
+ }
+
+ return afds;
+ }
+
+ // ListModel implementation
+
+ public uint n_items { get { return this.elements.length; } }
+
+ public GLib.Type item_type { get { return typeof (BinChunkChild); } }
+
+ public Object? get_item (uint i) {
+ if (i > this.elements.length)
+ return null;
+ return (Object) this.elements[i];
+ }
+
+ public uint get_n_items () {
+ return this.elements.length;
+ }
+
+ public GLib.Type get_item_type () {
+ return typeof (BinChunkChild);
+ }
+}
+
+/**
+ * A child of a {@link BinChunk}
+ */
+public abstract class Contacts.BinChunkChild : GLib.Object {
+
+ public Gee.MultiMap<string, string> parameters { get; set; }
+
+ /**
+ * Whether this BinChunkChild is empty. You can use the notify signal to
+ * listen for changes.
+ */
+ public abstract bool is_empty { get; }
+
+ /**
+ * The icon name that best represents this BinChunkChild
+ */
+ public abstract string icon_name { get; }
+
+ /**
+ * Creates an AbstractFieldDetails from the contents of this child
+ *
+ * If the contents are invalid (or empty), it returns null.
+ */
+ public abstract AbstractFieldDetails? create_afd ();
+
+ // A helper to change a string field with the proper propery notifies
+ protected void change_string_prop (string prop_name,
+ ref string old_value,
+ string new_value) {
+ if (new_value == old_value)
+ return;
+
+ bool notify_empty = ((new_value.strip () == "") != (old_value.strip () == ""));
+ // Don't strip value when setting the old one, since we don't want to
+ // prevent users from entering a space or a newline :D
+ old_value = new_value;
+ notify_property (prop_name);
+ if (notify_empty)
+ notify_property ("is-empty");
+ }
+}
diff --git a/src/core/contacts-birthday-chunk.vala b/src/core/contacts-birthday-chunk.vala
new file mode 100644
index 0000000..087da6a
--- /dev/null
+++ b/src/core/contacts-birthday-chunk.vala
@@ -0,0 +1,62 @@
+/*
+ * Copyright (C) 2022 Niels De Graef <nielsdegraef@gmail.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+using Folks;
+
+/**
+ * A {@link Chunk} that represents the birthday of a contact (similar to
+ * {@link Folks.BirthdayDetails}}.
+ */
+public class Contacts.BirthdayChunk : Chunk {
+
+ public DateTime? birthday {
+ get { return this._birthday; }
+ set {
+ if (this._birthday == null && value == null)
+ return;
+
+ if (this._birthday != null && value != null
+ && this._birthday.equal (value.to_utc ()))
+ return;
+
+ this._birthday = (value != null)? value.to_utc () : null;
+ notify_property ("birthday");
+ notify_property ("is-empty");
+ }
+ }
+ private DateTime? _birthday = null;
+
+ public override string property_name { get { return "birthday"; } }
+
+ public override bool is_empty { get { return this.birthday == null; } }
+
+ construct {
+ if (persona != null) {
+ return_if_fail (persona is BirthdayDetails);
+ persona.bind_property ("birthday", this, "birthday", BindingFlags.SYNC_CREATE);
+ }
+ }
+
+ public override Value? to_value () {
+ return this.birthday;
+ }
+
+ public override async void save_to_persona () throws GLib.Error
+ requires (this.persona is BirthdayDetails) {
+ yield ((BirthdayDetails) this.persona).change_birthday (this.birthday);
+ }
+}
diff --git a/src/core/contacts-chunk.vala b/src/core/contacts-chunk.vala
new file mode 100644
index 0000000..998ad69
--- /dev/null
+++ b/src/core/contacts-chunk.vala
@@ -0,0 +1,63 @@
+/*
+ * Copyright (C) 2022 Niels De Graef <nielsdegraef@gmail.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+using Folks;
+
+/**
+ * A "chunk" is a piece of data that describes a specific property of a
+ * {@link Contact}. Each chunk usually maps to a specific vCard property, or an
+ * interface related to a property of a {@link Folks.Persona}.
+ */
+public abstract class Contacts.Chunk : GLib.Object {
+
+ /** The associated persona (or null if we're creating a new one) */
+ public Persona? persona { get; construct set; default = null; }
+
+ /**
+ * The specific property of this chunk.
+ *
+ * Note that this should match with the string representation of a
+ * {@link Folks.PersonaDetail}.
+ */
+ public abstract string property_name { get; }
+
+ /**
+ * Whether this is empty. As an example, you can use to changes in this
+ * property to update any UI.
+ */
+ public abstract bool is_empty { get; }
+
+ /**
+ * A separate field to keep track of whether something has changed.
+ * If it did, we know we'll have to (possibly) save the changes.
+ */
+ public bool changed { get; protected set; default = false; }
+
+ /**
+ * Converts this chunk into a GLib.Value, as expected by API like
+ * {@link Folks.PersonaStore.add_persona_from_details}
+ *
+ * If the field is empty or non-existent, it should return null.
+ */
+ public abstract Value? to_value ();
+
+ /**
+ * Calls the appropriate API to save to the persona.
+ */
+ public abstract async void save_to_persona () throws GLib.Error
+ requires (this.persona != null);
+}
diff --git a/src/core/contacts-contact.vala b/src/core/contacts-contact.vala
new file mode 100644
index 0000000..889284f
--- /dev/null
+++ b/src/core/contacts-contact.vala
@@ -0,0 +1,303 @@
+/*
+ * Copyright (C) 2022 Niels De Graef <nielsdegraef@gmail.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+using Folks;
+
+/**
+ * A Contact is an object that represents a data model around a set of
+ * contact properties. This can either come from a {@link Folks.Individual}, an
+ * empty set (when creating contacts) or a different data source (like a vCard).
+ *
+ * Since the classes Folks provides assume valid data, we can't/shouldn't
+ * really use them (for example, a PostalAddresFieldDetails does not allow
+ * empty addresses), so that is another easy use for this separate class.
+ */
+public class Contacts.Contact : GLib.Object, GLib.ListModel {
+
+ private GenericArray<Chunk> chunks = new GenericArray<Chunk> ();
+
+ /** The underlying individual, if any */
+ public unowned Individual? individual { get; construct set; default = null; }
+
+ public unowned Store contacts_store { get; construct set; }
+
+ /** Similar to fetch_display_name(), but never returns null */
+ public string display_name {
+ owned get { return fetch_display_name () ?? _("Unnamed Person"); }
+ }
+
+ construct {
+ if (this.individual != null) {
+ this.individual.personas_changed.connect (on_individual_personas_changed);
+ on_individual_personas_changed (this.individual,
+ this.individual.personas,
+ Gee.Set.empty<Persona> ());
+ } else {
+ // At the very least let's add an empty full-name chunk
+ create_chunk ("full-name", null);
+ }
+ }
+
+ /** Creates a Contact that acts as a wrapper around an Individual */
+ public Contact.for_individual (Individual individual, Store contacts_store) {
+ Object (individual: individual, contacts_store: contacts_store);
+ }
+
+ /** Creates a new empty contact */
+ public Contact.for_new (Store contacts_store) {
+ Object (individual: null, contacts_store: contacts_store);
+ }
+
+ private void on_individual_personas_changed (Individual individual,
+ Gee.Set<Persona> added,
+ Gee.Set<Persona> removed) {
+ uint old_size = this.chunks.length;
+ foreach (var persona in added)
+ add_persona (persona);
+ items_changed (old_size - 1, 0, this.chunks.length - old_size);
+
+ foreach (var persona in removed) {
+ for (uint i = 0; i < this.chunks.length; i++) {
+ if (this.chunks[i].persona == persona) {
+ this.chunks.remove_index (i);
+ items_changed (i, 1, 0);
+ i--;
+ }
+ }
+ }
+ }
+
+ private void add_persona (Persona persona) {
+ if (persona is AliasDetails)
+ create_chunk_internal ("alias", persona);
+ if (persona is AvatarDetails)
+ create_chunk_internal ("avatar", persona);
+ if (persona is BirthdayDetails)
+ create_chunk_internal ("birthday", persona);
+ if (persona is EmailDetails)
+ create_chunk_internal ("email-addresses", persona);
+ if (persona is ImDetails)
+ create_chunk_internal ("im-addresses", persona);
+ if (persona is NameDetails) {
+ create_chunk_internal ("full-name", persona);
+ create_chunk_internal ("structured-name", persona);
+ create_chunk_internal ("nickname", persona);
+ }
+ if (persona is NoteDetails)
+ create_chunk_internal ("notes", persona);
+ if (persona is PhoneDetails)
+ create_chunk_internal ("phone-numbers", persona);
+ if (persona is PostalAddressDetails)
+ create_chunk_internal ("postal-addresses", persona);
+ if (persona is RoleDetails)
+ create_chunk_internal ("roles", persona);
+ if (persona is UrlDetails)
+ create_chunk_internal ("urls", persona);
+ }
+
+ public unowned Chunk? create_chunk (string property_name, Persona? persona) {
+ var pos = create_chunk_internal (property_name, persona);
+ if (pos == Gtk.INVALID_LIST_POSITION)
+ return null;
+ items_changed (pos, 0, 1);
+ return this.chunks[pos];
+ }
+
+ // Helper to create a chunk and return its position, without items_changed()
+ private uint create_chunk_internal (string property_name, Persona? persona) {
+ var chunk_gtype = chunk_gtype_for_property (property_name);
+ if (chunk_gtype == GLib.Type.NONE) {
+ debug ("unsupported property '%s', ignoring", property_name);
+ return Gtk.INVALID_LIST_POSITION;
+ }
+
+ var chunk = (Chunk) Object.new (chunk_gtype,
+ "persona", persona,
+ null);
+ this.chunks.add (chunk);
+ return this.chunks.length - 1;
+ }
+
+ private GLib.Type chunk_gtype_for_property (string property_name) {
+ switch (property_name) { // Please keep these sorted
+ case "alias":
+ return typeof (AliasChunk);
+ case "avatar":
+ return typeof (AvatarChunk);
+ case "birthday":
+ return typeof (BirthdayChunk);
+ case "email-addresses":
+ return typeof (EmailAddressesChunk);
+ case "full-name":
+ return typeof (FullNameChunk);
+ case "im-addresses":
+ return typeof (ImAddressesChunk);
+ case "nickname":
+ return typeof (NicknameChunk);
+ case "notes":
+ return typeof (NotesChunk);
+ case "phone-numbers":
+ return typeof (PhonesChunk);
+ case "postal-addresses":
+ return typeof (AddressesChunk);
+ case "roles":
+ return typeof (RolesChunk);
+ case "structured-name":
+ return typeof (StructuredNameChunk);
+ case "urls":
+ return typeof (UrlsChunk);
+ }
+
+ return GLib.Type.NONE;
+ }
+
+ /**
+ * Tries to get the name for the contact by iterating over the chunks that
+ * represent some form of name. If none is found, it returns null.
+ */
+ public string? fetch_name () {
+ var alias_chunk = get_most_relevant_chunk ("alias");
+ if (alias_chunk != null)
+ return ((AliasChunk) alias_chunk).alias;
+
+ var fn_chunk = get_most_relevant_chunk ("full-name");
+ if (fn_chunk != null)
+ return ((FullNameChunk) fn_chunk).full_name;
+
+ var sn_chunk = get_most_relevant_chunk ("structured-name");
+ if (sn_chunk != null)
+ return ((StructuredNameChunk) sn_chunk).structured_name.to_string ();
+
+ var nick_chunk = get_most_relevant_chunk ("nickname");
+ if (nick_chunk != null)
+ return ((NicknameChunk) nick_chunk).nickname;
+
+ return null;
+ }
+
+ /**
+ * Tries to get the displayable name for the contact. Similar to fetch_name,
+ * but also checks for fields that are not a name, but might still represent
+ * a contact (for example an email address)
+ */
+ public string? fetch_display_name () {
+ var name = fetch_name ();
+ if (name != null)
+ return name;
+
+ var emails_chunk = get_most_relevant_chunk ("email-addresses");
+ if (emails_chunk != null) {
+ var email = ((EmailAddressesChunk) emails_chunk).get_item (0);
+ return ((EmailAddress) email).raw_address;
+ }
+
+ var phones_chunk = get_most_relevant_chunk ("phone-numbers");
+ if (phones_chunk != null) {
+ var phone = ((PhonesChunk) phones_chunk).get_item (0);
+ return ((Phone) phone).raw_number;
+ }
+
+ return null;
+ }
+
+ /**
+ * A helper function to return the {@link Chunk} that best represents the
+ * property of the contact (or null if none).
+ */
+ public Chunk? get_most_relevant_chunk (string property_name, bool allow_empty = false) {
+ var filter = new ChunkFilter.for_property (property_name);
+ filter.allow_empty = allow_empty;
+ var chunks = new Gtk.FilterListModel (this, (owned) filter);
+
+ // From these chunks, select the one from the primary store. If there's
+ // none, just select the first one
+ unowned var primary_store = this.contacts_store.aggregator.primary_store;
+ for (uint i = 0; i < chunks.get_n_items (); i++) {
+ var chunk = (Chunk) chunks.get_item (i);
+ if (chunk.persona != null && chunk.persona.store == primary_store)
+ return chunk;
+ }
+ return (Chunk?) chunks.get_item (0);
+ }
+
+ public Object? get_item (uint i) {
+ if (i > this.chunks.length)
+ return null;
+ return this.chunks[i];
+ }
+
+ public uint get_n_items () {
+ return this.chunks.length;
+ }
+
+ public GLib.Type get_item_type () {
+ return typeof (Chunk);
+ }
+
+ /**
+ * Applies any pending changes to all chunks. This can mean either a new
+ * persona is made, or it is saved in the chunk's referenced persona.
+ */
+ public async void apply_changes () throws GLib.Error {
+ // For those that were a persona: save the properties using the API
+ for (uint i = 0; i < this.chunks.length; i++) {
+ unowned var chunk = this.chunks[i];
+ if (chunk.persona == null)
+ continue;
+
+ if (!(chunk.property_name in chunk.persona.writeable_properties)) {
+ warning ("Can't save to unwriteable property '%s' to persona %s",
+ chunk.property_name, chunk.persona.uid);
+ // TODO: maybe add a fallback to save to a different persona?
+ // We could maybe store it and add it to a new one, but that might make
+ // properties overlap
+ continue;
+ }
+
+ debug ("Saving property '%s' to persona %s",
+ chunk.property_name, chunk.persona.uid);
+ yield chunk.save_to_persona ();
+ debug ("Saved property '%s' to persona %s",
+ chunk.property_name, chunk.persona.uid);
+ }
+
+ // Find those without a persona, and save them into the primary store
+ var new_details = new HashTable<string, Value?> (str_hash, str_equal);
+ for (uint i = 0; i < this.chunks.length; i++) {
+ unowned var chunk = this.chunks[i];
+ if (chunk.persona != null)
+ continue;
+
+ var value = chunk.to_value ();
+ if (value == null // Skip empty properties
+ || value.peek_pointer () == null) // ugh, Vala
+ continue;
+
+ if (chunk.property_name in new_details)
+ warning ("Got multiple chunks for property '%s'", chunk.property_name);
+ new_details.insert (chunk.property_name, (owned) value);
+ }
+ if (new_details.size () != 0) {
+ debug ("Creating new persona with %u properties", new_details.size ());
+ unowned var primary_store = this.contacts_store.aggregator.primary_store;
+ return_if_fail (primary_store != null);
+ var persona = yield primary_store.add_persona_from_details (new_details);
+ debug ("Successfully created new persona %p", persona);
+ // FIXME: should we set the persona for these chunks?
+ }
+ }
+}
diff --git a/src/core/contacts-email-addresses-chunk.vala b/src/core/contacts-email-addresses-chunk.vala
new file mode 100644
index 0000000..1119a2c
--- /dev/null
+++ b/src/core/contacts-email-addresses-chunk.vala
@@ -0,0 +1,93 @@
+/*
+ * Copyright (C) 2022 Niels De Graef <nielsdegraef@gmail.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+using Folks;
+
+public class Contacts.EmailAddressesChunk : BinChunk {
+
+ public override string property_name { get { return "email-addresses"; } }
+
+ construct {
+ if (persona != null) {
+ return_if_fail (persona is EmailDetails);
+ unowned var email_details = (EmailDetails) persona;
+
+ foreach (var email_field in email_details.email_addresses) {
+ var email = new EmailAddress.from_field_details (email_field);
+ add_child (email);
+ }
+ }
+
+ emptiness_check ();
+ }
+
+ protected override BinChunkChild create_empty_child () {
+ return new EmailAddress ();
+ }
+
+ public override async void save_to_persona () throws GLib.Error
+ requires (this.persona is EmailDetails) {
+ var afds = (Gee.Set<EmailFieldDetails>) get_abstract_field_details ();
+ yield ((EmailDetails) this.persona).change_email_addresses (afds);
+ }
+}
+
+public class Contacts.EmailAddress : BinChunkChild {
+
+ public string raw_address {
+ get { return this._raw_address; }
+ set { change_string_prop ("raw-address", ref this._raw_address, value); }
+ }
+ private string _raw_address = "";
+
+ public override bool is_empty {
+ get { return this.raw_address.strip () == ""; }
+ }
+
+ public override string icon_name {
+ get { return "mail-unread-symbolic"; }
+ }
+
+ public EmailAddress () {
+ this.parameters = new Gee.HashMultiMap<string, string> ();
+ this.parameters["type"] = "PERSONAL";
+ }
+
+ public EmailAddress.from_field_details (EmailFieldDetails email_field) {
+ this.raw_address = email_field.value;
+ this.parameters = email_field.parameters;
+ }
+
+ /**
+ * Returns the TypeDescriptor that describes the type of the email address
+ * (for example personal, work, ...)
+ */
+ public TypeDescriptor get_email_address_type () {
+ return TypeSet.email.lookup_by_parameters (this.parameters);
+ }
+
+ public override AbstractFieldDetails? create_afd () {
+ if (this.is_empty)
+ return null;
+
+ return new EmailFieldDetails (this.raw_address, this.parameters);
+ }
+
+ public string get_mailto_uri () {
+ return "mailto:" + Uri.escape_string (this.raw_address, "@" , false);
+ }
+}
diff --git a/src/core/contacts-full-name-chunk.vala b/src/core/contacts-full-name-chunk.vala
new file mode 100644
index 0000000..647f556
--- /dev/null
+++ b/src/core/contacts-full-name-chunk.vala
@@ -0,0 +1,61 @@
+/*
+ * Copyright (C) 2022 Niels De Graef <nielsdegraef@gmail.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+using Folks;
+
+/**
+ * A {@link Chunk} that represents the full name of a contact as a single
+ * string (contrary to the structured name, where the name is split up in the
+ * several constituent parts}.
+ */
+public class Contacts.FullNameChunk : Chunk {
+
+ public string full_name {
+ get { return this._full_name; }
+ set {
+ if (this._full_name == value)
+ return;
+
+ bool was_empty = this.is_empty;
+ this._full_name = value;
+ notify_property ("full-name");
+ if (this.is_empty != was_empty)
+ notify_property ("is-empty");
+ }
+ }
+ private string _full_name = "";
+
+ public override string property_name { get { return "full-name"; } }
+
+ public override bool is_empty { get { return this._full_name.strip () == ""; } }
+
+ construct {
+ if (persona != null) {
+ return_if_fail (persona is NameDetails);
+ persona.bind_property ("full-name", this, "full-name", BindingFlags.SYNC_CREATE);
+ }
+ }
+
+ public override Value? to_value () {
+ return this.full_name;
+ }
+
+ public override async void save_to_persona () throws GLib.Error
+ requires (this.persona is NameDetails) {
+ yield ((NameDetails) this.persona).change_full_name (this.full_name);
+ }
+}
diff --git a/src/core/contacts-im-addresses-chunk.vala b/src/core/contacts-im-addresses-chunk.vala
new file mode 100644
index 0000000..031f804
--- /dev/null
+++ b/src/core/contacts-im-addresses-chunk.vala
@@ -0,0 +1,99 @@
+/*
+ * Copyright (C) 2022 Niels De Graef <nielsdegraef@gmail.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+using Folks;
+
+/**
+ * A {@link Chunk} that represents the internet messaging (IM) addresses of a
+ * contact (similar to {@link Folks.ImDetails}}. Each element is a
+ * {@link ImAddress}.
+ */
+public class Contacts.ImAddressesChunk : BinChunk {
+
+ public override string property_name { get { return "im-addresses"; } }
+
+ construct {
+ if (persona != null) {
+ return_if_fail (persona is ImDetails);
+ unowned var im_details = (ImDetails) persona;
+
+ var iter = im_details.im_addresses.map_iterator ();
+ while (iter.next ()) {
+ var protocol = iter.get_key ();
+ var im = new ImAddress.from_field_details (iter.get_value (), protocol);
+ add_child (im);
+ }
+ }
+
+ emptiness_check ();
+ }
+
+ protected override BinChunkChild create_empty_child () {
+ return new ImAddress ();
+ }
+
+ public override async void save_to_persona () throws GLib.Error
+ requires (this.persona is ImDetails) {
+ // We can't use get_abstract_field_details() here, since we need the
+ // protocol as well, and to use a Gee.MultiMap for it
+ var afds = new Gee.HashMultiMap<string, ImFieldDetails> ();
+ for (uint i = 0; i < get_n_items (); i++) {
+ var im_addr = (ImAddress) get_item (i);
+ var afd = (ImFieldDetails) im_addr.create_afd ();
+ if (afd != null)
+ afds[im_addr.protocol] = afd;
+ }
+
+ yield ((ImDetails) this.persona).change_im_addresses (afds);
+ }
+}
+
+public class Contacts.ImAddress : BinChunkChild {
+
+ public string protocol { get; private set; default = ""; }
+
+ public string address {
+ get { return this._address; }
+ set { change_string_prop ("address", ref this._address, value); }
+ }
+ private string _address = "";
+
+ public override bool is_empty {
+ get { return this.address.strip () == ""; }
+ }
+
+ public override string icon_name {
+ get { return "chat-symbolic"; }
+ }
+
+ public ImAddress () {
+ this.parameters = new Gee.HashMultiMap<string, string> ();
+ }
+
+ public ImAddress.from_field_details (ImFieldDetails im_field, string protocol) {
+ this.address = im_field.value;
+ this.protocol = protocol;
+ this.parameters = im_field.parameters;
+ }
+
+ public override AbstractFieldDetails? create_afd () {
+ if (this.is_empty)
+ return null;
+
+ return new ImFieldDetails (this.address, this.parameters);
+ }
+}
diff --git a/src/core/contacts-nickname-chunk.vala b/src/core/contacts-nickname-chunk.vala
new file mode 100644
index 0000000..ba505f0
--- /dev/null
+++ b/src/core/contacts-nickname-chunk.vala
@@ -0,0 +1,60 @@
+/*
+ * Copyright (C) 2022 Niels De Graef <nielsdegraef@gmail.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+using Folks;
+
+/**
+ * A {@link Chunk} that represents the nickname of a contact.
+ */
+public class Contacts.NicknameChunk : Chunk {
+
+ public string nickname {
+ get { return this._nickname; }
+ set {
+ if (this._nickname == value)
+ return;
+
+ bool was_empty = this.is_empty;
+ this._nickname = value;
+ notify_property ("nickname");
+ if (this.is_empty != was_empty)
+ notify_property ("is-empty");
+ }
+ }
+ private string _nickname = "";
+
+ public override string property_name { get { return "nickname"; } }
+
+ public override bool is_empty { get { return this._nickname.strip () == ""; } }
+
+ construct {
+ if (persona != null) {
+ return_if_fail (persona is NameDetails);
+ persona.bind_property ("nickname", this, "nickname", BindingFlags.SYNC_CREATE);
+ }
+ }
+
+ public override Value? to_value () {
+ return this.nickname;
+ }
+
+ public override async void save_to_persona () throws GLib.Error
+ requires (this.persona is NameDetails) {
+
+ yield ((NameDetails) this.persona).change_nickname (this.nickname);
+ }
+}
diff --git a/src/core/contacts-notes-chunk.vala b/src/core/contacts-notes-chunk.vala
new file mode 100644
index 0000000..45b5c43
--- /dev/null
+++ b/src/core/contacts-notes-chunk.vala
@@ -0,0 +1,85 @@
+/*
+ * Copyright (C) 2022 Niels De Graef <nielsdegraef@gmail.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+using Folks;
+
+/**
+ * A {@link Chunk} that represents the freeform notes attached to a contact
+ * (similar to {@link Folks.NoteDetails}}. Each element is a {@link Note}.
+ */
+public class Contacts.NotesChunk : BinChunk {
+
+ public override string property_name { get { return "notes"; } }
+
+ construct {
+ if (persona != null) {
+ return_if_fail (persona is NoteDetails);
+ unowned var note_details = (NoteDetails) persona;
+
+ foreach (var note_field in note_details.notes) {
+ var note = new Note.from_field_details (note_field);
+ add_child (note);
+ }
+ }
+
+ emptiness_check ();
+ }
+
+ protected override BinChunkChild create_empty_child () {
+ return new Note ();
+ }
+
+ public override async void save_to_persona () throws GLib.Error
+ requires (this.persona is PhoneDetails) {
+ var afds = (Gee.Set<NoteFieldDetails>) get_abstract_field_details ();
+ yield ((NoteDetails) this.persona).change_notes (afds);
+ }
+}
+
+public class Contacts.Note : BinChunkChild {
+
+ public string text {
+ get { return this._text; }
+ set { change_string_prop ("text", ref this._text, value); }
+ }
+ private string _text = "";
+
+ public override bool is_empty {
+ get { return this.text.strip () == ""; }
+ }
+
+ public override string icon_name {
+ get { return "note-symbolic"; }
+ }
+
+ public Note () {
+ this.parameters = new Gee.HashMultiMap<string, string> ();
+ this.parameters["type"] = "PERSONAL";
+ }
+
+ public Note.from_field_details (NoteFieldDetails note_field) {
+ this.text = note_field.value;
+ this.parameters = note_field.parameters;
+ }
+
+ public override AbstractFieldDetails? create_afd () {
+ if (this.is_empty)
+ return null;
+
+ return new NoteFieldDetails (this.text, this.parameters);
+ }
+}
diff --git a/src/core/contacts-phones-chunk.vala b/src/core/contacts-phones-chunk.vala
new file mode 100644
index 0000000..8135d98
--- /dev/null
+++ b/src/core/contacts-phones-chunk.vala
@@ -0,0 +1,97 @@
+/*
+ * Copyright (C) 2022 Niels De Graef <nielsdegraef@gmail.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+using Folks;
+
+/**
+ * A {@link Chunk} that represents the phone numbers of a contact (similar to
+ * {@link Folks.PhoneDetails}}. Each element is a {@link Phone}.
+ */
+public class Contacts.PhonesChunk : BinChunk {
+
+ public override string property_name { get { return "phone-numbers"; } }
+
+ construct {
+ if (persona != null) {
+ return_if_fail (persona is PhoneDetails);
+ unowned var phone_details = (PhoneDetails) persona;
+
+ foreach (var phone_field in phone_details.phone_numbers) {
+ var phone = new Phone.from_field_details (phone_field);
+ add_child (phone);
+ }
+ }
+
+ emptiness_check ();
+ }
+
+ protected override BinChunkChild create_empty_child () {
+ return new Phone ();
+ }
+
+ public override async void save_to_persona () throws GLib.Error
+ requires (this.persona is PhoneDetails) {
+ var afds = (Gee.Set<PhoneFieldDetails>) get_abstract_field_details ();
+ yield ((PhoneDetails) this.persona).change_phone_numbers (afds);
+ }
+}
+
+public class Contacts.Phone : BinChunkChild {
+
+ /**
+ * The "raw" phone number as inputted by a user or from a contact. It may or
+ * may not be an actual valid phone number.
+ */
+ public string raw_number {
+ get { return this._raw_number; }
+ set { change_string_prop ("raw-number", ref this._raw_number, value); }
+ }
+ private string _raw_number = "";
+
+ public override bool is_empty {
+ get { return this.raw_number.strip () == ""; }
+ }
+
+ public override string icon_name {
+ get { return "phone-symbolic"; }
+ }
+
+ public Phone () {
+ this.parameters = new Gee.HashMultiMap<string, string> ();
+ this.parameters["type"] = "CELL";
+ }
+
+ public Phone.from_field_details (PhoneFieldDetails phone_field) {
+ this.raw_number = phone_field.value;
+ this.parameters = phone_field.parameters;
+ }
+
+ /**
+ * Returns the TypeDescriptor that describes the type of phone number
+ * (for example mobile, work, fax, ...)
+ */
+ public TypeDescriptor get_phone_type () {
+ return TypeSet.phone.lookup_by_parameters (this.parameters);
+ }
+
+ public override AbstractFieldDetails? create_afd () {
+ if (this.is_empty)
+ return null;
+
+ return new PhoneFieldDetails (this.raw_number, this.parameters);
+ }
+}
diff --git a/src/core/contacts-roles-chunk.vala b/src/core/contacts-roles-chunk.vala
new file mode 100644
index 0000000..bec585b
--- /dev/null
+++ b/src/core/contacts-roles-chunk.vala
@@ -0,0 +1,94 @@
+/*
+ * Copyright (C) 2022 Niels De Graef <nielsdegraef@gmail.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+using Folks;
+
+/**
+ * A {@link Chunk} that represents the organizations and/or roles of a contact
+ * (similar to {@link Folks.RoleDetails}}. Each element is a
+ * {@link Contacts.OrgRole}.
+ */
+public class Contacts.RolesChunk : BinChunk {
+
+ public override string property_name { get { return "roles"; } }
+
+ construct {
+ if (persona != null) {
+ return_if_fail (persona is RoleDetails);
+ unowned var role_details = (RoleDetails) persona;
+
+ foreach (var role_field in role_details.roles) {
+ var role = new OrgRole.from_field_details (role_field);
+ add_child (role);
+ }
+ }
+
+ emptiness_check ();
+ }
+
+ protected override BinChunkChild create_empty_child () {
+ return new OrgRole ();
+ }
+
+ public override async void save_to_persona () throws GLib.Error
+ requires (this.persona is RoleDetails) {
+ var afds = (Gee.Set<RoleFieldDetails>) get_abstract_field_details ();
+ yield ((RoleDetails) this.persona).change_roles (afds);
+ }
+}
+
+public class Contacts.OrgRole : BinChunkChild {
+
+ public Role role { get; private set; default = new Role (); }
+
+ public override bool is_empty {
+ get { return this.role.is_empty (); }
+ }
+
+ public override string icon_name {
+ get { return "building-symbolic"; }
+ }
+
+ public OrgRole () {
+ this.parameters = new Gee.HashMultiMap<string, string> ();
+ }
+
+ public OrgRole.from_field_details (RoleFieldDetails role_field) {
+ this.role = role_field.value;
+ this.parameters = role_field.parameters;
+ }
+
+ public override AbstractFieldDetails? create_afd () {
+ if (this.is_empty)
+ return null;
+
+ return new RoleFieldDetails (this.role, this.parameters);
+ }
+
+ public string to_string () {
+ if (this.role.title != "") {
+ if (this.role.organisation_name != "") {
+ // TRANSLATORS: "$ROLE at $ORGANISATION", e.g. "CEO at Linux Inc."
+ return _("%s at %s").printf (this.role.title, this.role.organisation_name);
+ }
+
+ return this.role.title;
+ }
+
+ return this.role.organisation_name;
+ }
+}
diff --git a/src/core/contacts-structured-name-chunk.vala b/src/core/contacts-structured-name-chunk.vala
new file mode 100644
index 0000000..07cbc8f
--- /dev/null
+++ b/src/core/contacts-structured-name-chunk.vala
@@ -0,0 +1,69 @@
+/*
+ * Copyright (C) 2022 Niels De Graef <nielsdegraef@gmail.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+using Folks;
+
+/**
+ * A {@link Chunk} that represents the structured name of a contact.
+ *
+ * The structured represents a full name split in its constituent parts (given
+ * name, family name, etc.)
+ */
+public class Contacts.StructuredNameChunk : Chunk {
+
+ public StructuredName structured_name {
+ get { return this._structured_name; }
+ set {
+ if (this._structured_name == value)
+ return;
+ if (this._structured_name != null && value != null
+ && this._structured_name.equal (value))
+ return;
+
+ bool was_empty = this.is_empty;
+ this._structured_name = value;
+ notify_property ("structured-name");
+ if (this.is_empty != was_empty)
+ notify_property ("is-empty");
+ }
+ }
+ private StructuredName _structured_name = new StructuredName.simple (null, null);
+
+ public override string property_name { get { return "structured-name"; } }
+
+ public override bool is_empty {
+ get {
+ return this._structured_name == null || this._structured_name.is_empty ();
+ }
+ }
+
+ construct {
+ if (persona != null) {
+ return_if_fail (persona is NameDetails);
+ persona.bind_property ("structured-name", this, "structured-name", BindingFlags.SYNC_CREATE);
+ }
+ }
+
+ public override Value? to_value () {
+ return this.structured_name;
+ }
+
+ public override async void save_to_persona () throws GLib.Error
+ requires (this.persona is NameDetails) {
+ yield ((NameDetails) this.persona).change_structured_name (this.structured_name);
+ }
+}
diff --git a/src/core/contacts-urls-chunk.vala b/src/core/contacts-urls-chunk.vala
new file mode 100644
index 0000000..671fc4d
--- /dev/null
+++ b/src/core/contacts-urls-chunk.vala
@@ -0,0 +1,95 @@
+/*
+ * Copyright (C) 2022 Niels De Graef <nielsdegraef@gmail.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+using Folks;
+
+/**
+ * A {@link Chunk} that represents the associated URLs of a contact (similar to
+ * {@link Folks.UrlDetails}}. Each element is a {@link Contacts.Url}.
+ */
+public class Contacts.UrlsChunk : BinChunk {
+
+ public override string property_name { get { return "urls"; } }
+
+ construct {
+ if (persona != null) {
+ return_if_fail (persona is UrlDetails);
+ unowned var url_details = (UrlDetails) persona;
+
+ foreach (var url_field in url_details.urls) {
+ var url = new Url.from_field_details (url_field);
+ add_child (url);
+ }
+ }
+
+ emptiness_check ();
+ }
+
+ protected override BinChunkChild create_empty_child () {
+ return new Url ();
+ }
+
+ public override async void save_to_persona () throws GLib.Error
+ requires (this.persona is UrlDetails) {
+ var afds = (Gee.Set<UrlFieldDetails>) get_abstract_field_details ();
+ yield ((UrlDetails) this.persona).change_urls (afds);
+ }
+}
+
+public class Contacts.Url : BinChunkChild {
+
+ public string raw_url {
+ get { return this._raw_url; }
+ set { change_string_prop ("raw-url", ref this._raw_url, value); }
+ }
+ private string _raw_url = "";
+
+ public override bool is_empty {
+ get { return this.raw_url.strip () == ""; }
+ }
+
+ public override string icon_name {
+ get { return "website-symbolic"; }
+ }
+
+ public Url () {
+ this.parameters = new Gee.HashMultiMap<string, string> ();
+ this.parameters["type"] = "PERSONAL";
+ }
+
+ public Url.from_field_details (UrlFieldDetails url_field) {
+ this.raw_url = url_field.value;
+ this.parameters = url_field.parameters;
+ }
+
+ /**
+ * Tries to return an absolute URL (with a scheme).
+ * Since we know contact URL values are for web addresses, we try to fall
+ * back to https if there is no known scheme
+ */
+ public string get_absolute_url () {
+ string scheme = Uri.parse_scheme (this.raw_url);
+ return (scheme != null)? this.raw_url : "https://" + this.raw_url;
+ }
+
+ public override AbstractFieldDetails? create_afd () {
+ if (this.is_empty)
+ return null;
+
+ return new UrlFieldDetails (this.raw_url, this.parameters);
+ }
+}
diff --git a/src/meson.build b/src/meson.build
index b439d60..3d97c53 100644
--- a/src/meson.build
+++ b/src/meson.build
@@ -1,4 +1,4 @@
-subdir ('io')
+subdir('io')
# GSettings
compiled = gnome.compile_schemas()
@@ -8,10 +8,30 @@ install_data('org.gnome.Contacts.gschema.xml',
# Common library
libcontacts_sources = files(
+ 'core/contacts-addresses-chunk.vala',
+ 'core/contacts-alias-chunk.vala',
+ 'core/contacts-avatar-chunk.vala',
+ 'core/contacts-bin-chunk.vala',
+ 'core/contacts-birthday-chunk.vala',
+ 'core/contacts-chunk.vala',
+ 'core/contacts-contact.vala',
+ 'core/contacts-email-addresses-chunk.vala',
+ 'core/contacts-full-name-chunk.vala',
+ 'core/contacts-im-addresses-chunk.vala',
+ 'core/contacts-nickname-chunk.vala',
+ 'core/contacts-notes-chunk.vala',
+ 'core/contacts-phones-chunk.vala',
+ 'core/contacts-roles-chunk.vala',
+ 'core/contacts-structured-name-chunk.vala',
+ 'core/contacts-urls-chunk.vala',
+
'contacts-abstract-field-details-sorter.vala',
+ 'contacts-chunk-filter.vala',
+ 'contacts-chunk-empty-filter.vala',
+ 'contacts-chunk-property-filter.vala',
+ 'contacts-chunk-sorter.vala',
'contacts-delete-operation.vala',
'contacts-esd-setup.vala',
- 'contacts-fake-persona-store.vala',
'contacts-im-service.vala',
'contacts-import-operation.vala',
'contacts-individual-sorter.vala',
@@ -88,8 +108,6 @@ contacts_vala_sources = files(
'contacts-contact-pane.vala',
'contacts-contact-sheet.vala',
'contacts-crop-dialog.vala',
- 'contacts-editor-persona.vala',
- 'contacts-editor-property.vala',
'contacts-link-suggestion-grid.vala',
'contacts-linked-personas-dialog.vala',
'contacts-main-window.vala',