[gnome-contacts/nielsdg/chunks: 2/2] Introduce the concept of Contacts.Chunk




commit ce50169e67af7c169a8291fce082b93ee5d708f8
Author: Niels De Graef <nielsdegraef gmail com>
Date:   Tue Aug 16 12:36:08 2022 +0200

    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.

 po/POTFILES.in                               |  16 +
 po/POTFILES.skip                             |  16 +
 src/contacts-avatar-selector.vala            |  57 +-
 src/contacts-avatar.vala                     |  73 ++-
 src/contacts-chunk-empty-filter.vala         |  50 ++
 src/contacts-chunk-filter.vala               |  68 +++
 src/contacts-chunk-property-filter.vala      |  77 +++
 src/contacts-chunk-sorter.vala               |  73 +++
 src/contacts-contact-editor.vala             | 779 +++++++++++++++++++++++++--
 src/contacts-contact-pane.vala               | 170 ++----
 src/contacts-contact-sheet.vala              | 539 +++++++++---------
 src/contacts-editor-persona.vala             | 165 ------
 src/contacts-editor-property.vala            | 761 --------------------------
 src/contacts-fake-persona-store.vala         | 612 ---------------------
 src/contacts-main-window.vala                |   2 +-
 src/contacts-persona-sorter.vala             |  22 +-
 src/contacts-type-combo.vala                 |  10 +
 src/contacts-type-descriptor.vala            |  29 +-
 src/contacts-typeset.vala                    |  15 +-
 src/contacts-utils.vala                      | 147 -----
 src/core/contacts-addresses-chunk.vala       | 126 +++++
 src/core/contacts-alias-chunk.vala           |  57 ++
 src/core/contacts-avatar-chunk.vala          |  53 ++
 src/core/contacts-bin-chunk.vala             | 171 ++++++
 src/core/contacts-birthday-chunk.vala        |  62 +++
 src/core/contacts-chunk.vala                 |  63 +++
 src/core/contacts-contact.vala               | 294 ++++++++++
 src/core/contacts-email-addresses-chunk.vala |  89 +++
 src/core/contacts-full-name-chunk.vala       |  61 +++
 src/core/contacts-im-addresses-chunk.vala    |  99 ++++
 src/core/contacts-nickname-chunk.vala        |  60 +++
 src/core/contacts-notes-chunk.vala           |  89 +++
 src/core/contacts-phones-chunk.vala          |  93 ++++
 src/core/contacts-roles-chunk.vala           |  94 ++++
 src/core/contacts-structured-name-chunk.vala |  69 +++
 src/core/contacts-urls-chunk.vala            |  89 +++
 src/meson.build                              |  26 +-
 37 files changed, 3085 insertions(+), 2191 deletions(-)
---
diff --git a/po/POTFILES.in b/po/POTFILES.in
index 4595a3e2..d10c6feb 100644
--- a/po/POTFILES.in
+++ b/po/POTFILES.in
@@ -40,6 +40,22 @@ src/contacts-type-descriptor.vala
 src/contacts-typeset.vala
 src/contacts-unlink-operation.vala
 src/contacts-utils.vala
+src/core/contacts-addresses-chunk.vala
+src/core/contacts-alias-chunk.vala
+src/core/contacts-avatar-chunk.vala
+src/core/contacts-bin-chunk.vala
+src/core/contacts-birthday-chunk.vala
+src/core/contacts-chunk.vala
+src/core/contacts-contact.vala
+src/core/contacts-email-addresses-chunk.vala
+src/core/contacts-full-name-chunk.vala
+src/core/contacts-im-addresses-chunk.vala
+src/core/contacts-nickname-chunk.vala
+src/core/contacts-notes-chunk.vala
+src/core/contacts-phones-chunk.vala
+src/core/contacts-roles-chunk.vala
+src/core/contacts-structured-name-chunk.vala
+src/core/contacts-urls-chunk.vala
 src/io/contacts-io-parse-operation.vala
 src/main.vala
 src/org.gnome.Contacts.gschema.xml
diff --git a/po/POTFILES.skip b/po/POTFILES.skip
index 7c18cc9b..c37686c8 100644
--- a/po/POTFILES.skip
+++ b/po/POTFILES.skip
@@ -30,5 +30,21 @@ src/contacts-type-descriptor.c
 src/contacts-typeset.c
 src/contacts-unlink-operation.c
 src/contacts-utils.c
+src/core/contacts-addresses-chunk.c
+src/core/contacts-alias-chunk.c
+src/core/contacts-avatar-chunk.c
+src/core/contacts-bin-chunk.c
+src/core/contacts-birthday-chunk.c
+src/core/contacts-chunk.c
+src/core/contacts-contact.c
+src/core/contacts-email-addresses-chunk.c
+src/core/contacts-full-name-chunk.c
+src/core/contacts-im-addresses-chunk.c
+src/core/contacts-nickname-chunk.c
+src/core/contacts-notes-chunk.c
+src/core/contacts-phones-chunk.c
+src/core/contacts-roles-chunk.c
+src/core/contacts-structured-name-chunk.c
+src/core/contacts-urls-chunk.c
 src/io/contacts-io-parse-operation.c
 src/main.c
diff --git a/src/contacts-avatar-selector.vala b/src/contacts-avatar-selector.vala
index dfdb7c56..cfe92aaa 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 bbd9dc77..9f26f119 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 00000000..64009e3c
--- /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 00000000..71c2441c
--- /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 00000000..d2c82330
--- /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 00000000..e47f440b
--- /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 c68b4358..5987b3b5 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 ("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 b375a9cb..9e88e7f9 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);
+    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 (this.store);
+    } 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 ();
+    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 81b32937..1d447601 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,191 +177,128 @@ 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 Gtk.Widget create_widget_for_roles (Chunk chunk) {
+    unowned var roles_chunk = chunk as RolesChunk;
+    return_if_fail (roles_chunk != null);
 
-  private void add_roles (Persona persona, string property) {
-    unowned var details = persona as RoleDetails;
-    if (details == null)
-      return;
+    var group = new ContactSheetGroup ();
 
-    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);
-
-      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_display_type ());
 
       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_display_type ());
+      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.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),
+                      fallback_to_https (url.url),
                       Gdk.CURRENT_TIME);
       });
 
-      rows.append (row);
+      group.add (row);
     }
 
-    this.attach_rows (rows);
+    return group;
   }
 
   // When the url doesn't contain a scheme we fallback to http
@@ -379,89 +310,127 @@ public class Contacts.ContactSheet : Gtk.Grid {
     return url;
   }
 
-  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 group = new ContactSheetGroup ();
 
-    var rows = new GLib.List<Gtk.ListBoxRow> ();
-    foreach (var note in note_details.notes) {
-      if (note.value == "")
+    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_display_type ());
 
       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);
     }
 
-    this.attach_rows (rows);
+    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.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-main-window.vala b/src/contacts-main-window.vala
index f6f465c9..e2fbeecd 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 4ba40060..ff0aeaf5 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 3f252f7d..2940a5e0 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 8335f563..c4e4dc07 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 733de790..8e462996 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 4bfd8106..7edaa7c1 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 00000000..74fb89db
--- /dev/null
+++ b/src/core/contacts-addresses-chunk.vala
@@ -0,0 +1,126 @@
+/*
+ * 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;
+  }
+
+  public unowned string get_display_type () {
+    return TypeSet.general.lookup_by_parameters (this.parameters).display_name;
+  }
+
+  public override AbstractFieldDetails? create_afd () {
+    if (this.is_empty)
+      return null;
+
+    return new PostalAddressFieldDetails (this.address, this.parameters);
+  }
+
+  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);
+  }
+
+  public string to_maps_uri () {
+    var address_parts = to_string (" ");
+    return "maps:q=%s".printf (GLib.Uri.escape_string (address_parts));
+  }
+}
diff --git a/src/core/contacts-alias-chunk.vala b/src/core/contacts-alias-chunk.vala
new file mode 100644
index 00000000..921e9cf2
--- /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 00000000..56dbce27
--- /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 00000000..3229ddda
--- /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 00000000..087da6a6
--- /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 00000000..998ad690
--- /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 00000000..2cc4f22f
--- /dev/null
+++ b/src/core/contacts-contact.vala
@@ -0,0 +1,294 @@
+/*
+ * 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; }
+
+  /**
+   * 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) {
+    Object (individual: individual);
+  }
+
+  /** Creates a new empty contact */
+  public Contact.for_new () {
+    Object (individual: null);
+  }
+
+  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);
+    //XXX primary first
+    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
+   */
+  public async void apply_changes (Store contacts_store) 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 ());
+      var primary_store = 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 00000000..930b1c8b
--- /dev/null
+++ b/src/core/contacts-email-addresses-chunk.vala
@@ -0,0 +1,89 @@
+/*
+ * 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;
+  }
+
+  public unowned string get_display_type () {
+    return TypeSet.email.lookup_by_parameters (this.parameters).display_name;
+  }
+
+  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 00000000..647f5561
--- /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 00000000..031f8045
--- /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 00000000..ba505f08
--- /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 00000000..9c47c1ec
--- /dev/null
+++ b/src/core/contacts-notes-chunk.vala
@@ -0,0 +1,89 @@
+/*
+ * 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 unowned string get_display_type () {
+    return TypeSet.general.lookup_by_parameters (this.parameters).display_name;
+  }
+
+  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 00000000..4d01e53d
--- /dev/null
+++ b/src/core/contacts-phones-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;
+
+/**
+ * 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;
+  }
+
+  public unowned string get_display_type () {
+    return TypeSet.phone.lookup_by_parameters (this.parameters).display_name;
+  }
+
+  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 00000000..bec585b2
--- /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 00000000..07cbc8f9
--- /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 00000000..cdcb6fc5
--- /dev/null
+++ b/src/core/contacts-urls-chunk.vala
@@ -0,0 +1,89 @@
+/*
+ * 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 url {
+    get { return this._url; }
+    set { change_string_prop ("url", ref this._url, value); }
+  }
+  private string _url = "";
+
+  public override bool is_empty {
+    get { return this.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.url = url_field.value;
+    this.parameters = url_field.parameters;
+  }
+
+  public unowned string get_display_type () {
+    return TypeSet.general.lookup_by_parameters (this.parameters).display_name;
+  }
+
+  public override AbstractFieldDetails? create_afd () {
+    if (this.is_empty)
+      return null;
+
+    return new UrlFieldDetails (this.url, this.parameters);
+  }
+}
diff --git a/src/meson.build b/src/meson.build
index b439d601..3d97c53b 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',


[Date Prev][Date Next]   [Thread Prev][Thread Next]   [Thread Index] [Date Index] [Author Index]