[gnome-contacts/wip/nielsdg/plugins-for-form-fields: 10/11] WIP: Rewrite the ContactEditor/ContactSheet



commit 1082f480f1a5fd143b43f826b69cdc080a44602a
Author: Niels De Graef <nielsdegraef gmail com>
Date:   Sat Dec 1 17:42:24 2018 +0100

    WIP: Rewrite the ContactEditor/ContactSheet

 data/ui/contacts-contact-form.ui     |  14 +
 data/ui/style.css                    |   4 +
 src/contacts-contact-editor.vala     | 889 ++++----------------------------
 src/contacts-contact-form-field.vala | 975 +++++++++++++++++++++++++++++++++++
 src/contacts-contact-form.vala       | 109 +++-
 src/contacts-contact-pane.vala       |  54 +-
 src/contacts-contact-sheet.vala      | 242 ++-------
 src/contacts-contact.vala            | 153 +-----
 src/contacts-type-descriptor.vala    |  37 +-
 src/contacts-utils.vala              |  68 ++-
 src/meson.build                      |   1 +
 11 files changed, 1334 insertions(+), 1212 deletions(-)
---
diff --git a/data/ui/contacts-contact-form.ui b/data/ui/contacts-contact-form.ui
index b0c1fd1..6233fad 100644
--- a/data/ui/contacts-contact-form.ui
+++ b/data/ui/contacts-contact-form.ui
@@ -3,6 +3,7 @@
   <!-- interface-requires gtk+ 3.22 -->
   <template class="ContactsContactForm" parent="GtkGrid">
     <property name="visible">True</property>
+    <property name="orientation">vertical</property>
     <child>
       <object class="GtkScrolledWindow" id="main_sw">
         <property name="visible">True</property>
@@ -24,7 +25,20 @@
                 <property name="row_spacing">12</property>
                 <property name="column_spacing">16</property>
                 <property name="margin">36</property>
+                <property name="margin_top">30</property>
                 <property name="margin_bottom">24</property>
+                <child>
+                  <object class="GtkListBox" id="form_container">
+                    <property name="visible">True</property>
+                    <property name="hexpand">True</property>
+                    <property name="selection_mode">none</property>
+                  </object>
+                  <packing>
+                    <property name="top_attach">1</property>
+                    <property name="left_attach">0</property>
+                    <property name="width">2</property>
+                  </packing>
+                </child>
               </object>
             </child>
           </object>
diff --git a/data/ui/style.css b/data/ui/style.css
index d77b446..6526c21 100644
--- a/data/ui/style.css
+++ b/data/ui/style.css
@@ -20,6 +20,10 @@ row.contact-data-row {
   background-color: mix(@theme_bg_color, @theme_base_color, 0.4);
 }
 
+.contacts-contact-form list {
+  background-color: rgba(0, 0, 0, 0);
+}
+
 .contacts-suggestion {
   border-top: 1px solid @borders;
   background-color: shade(@theme_bg_color, 0.9);
diff --git a/src/contacts-contact-editor.vala b/src/contacts-contact-editor.vala
index dc5ad4a..cdd4bd4 100644
--- a/src/contacts-contact-editor.vala
+++ b/src/contacts-contact-editor.vala
@@ -19,46 +19,6 @@ using Gtk;
 using Folks;
 using Gee;
 
-public class Contacts.AddressEditor : Box {
-  public Entry? entries[7];  /* must be the number of elements in postal_element_props */
-  public PostalAddressFieldDetails details;
-
-  public const string[] postal_element_props = {"street", "extension", "locality", "region", "postal_code", 
"po_box", "country"};
-  public static string[] postal_element_names = {_("Street"), _("Extension"), _("City"), 
_("State/Province"), _("Zip/Postal Code"), _("PO box"), _("Country")};
-
-  public signal void changed ();
-
-  public AddressEditor (PostalAddressFieldDetails _details) {
-    set_hexpand (true);
-    set_orientation (Orientation.VERTICAL);
-
-    details = _details;
-
-    for (int i = 0; i < entries.length; i++) {
-      string postal_part;
-      details.value.get (AddressEditor.postal_element_props[i], out postal_part);
-
-      entries[i] = new Entry ();
-      entries[i].set_hexpand (true);
-      entries[i].set ("placeholder-text", AddressEditor.postal_element_names[i]);
-
-      if (postal_part != null)
-       entries[i].set_text (postal_part);
-
-      entries[i].get_style_context ().add_class ("contacts-postal-entry");
-      add (entries[i]);
-
-      entries[i].changed.connect (() => {
-         changed ();
-       });
-    }
-  }
-
-  public override void grab_focus () {
-    entries[0].grab_focus ();
-  }
-}
-
 /**
  * A widget that allows the user to edit a given {@link Contact}.
  */
@@ -73,10 +33,6 @@ public class Contacts.ContactEditor : ContactForm {
 
   private weak Widget focus_widget;
 
-  private Entry name_entry;
-
-  private Avatar avatar;
-
   [GtkChild]
   private MenuButton add_detail_button;
 
@@ -86,23 +42,6 @@ public class Contacts.ContactEditor : ContactForm {
   [GtkChild]
   public Button remove_button;
 
-  public struct PropertyData {
-    Persona? persona;
-    Value value;
-  }
-
-  struct RowData {
-    AbstractFieldDetails details;
-  }
-
-  struct Field {
-    bool changed;
-    HashMap<int, RowData?> rows;
-  }
-
-  /* the key of the hash_map is the uid of the persona */
-  private HashMap<string, HashMap<string, Field?>> writable_personas;
-
   public bool has_birthday_row {
     get; private set; default = false;
   }
@@ -116,7 +55,6 @@ public class Contacts.ContactEditor : ContactForm {
   }
 
   construct {
-    this.writable_personas = new HashMap<string, HashMap<string, Field?>> ();
     this.container_grid.size_allocate.connect(on_container_grid_size_allocate);
   }
 
@@ -146,679 +84,79 @@ public class Contacts.ContactEditor : ContactForm {
   }
 
   private void fill_in_contact () {
-    int i = 3;
-    int last_store_position = 0;
-    bool is_first_persona = true;
-
-    var personas = this.contact.get_personas_for_display ();
-    foreach (var p in personas) {
-      if (!is_first_persona) {
-        this.container_grid.attach (create_persona_store_label (p), 0, i, 2);
-        last_store_position = ++i;
-      }
-
-      var rw_props = sort_persona_properties (p.writeable_properties);
-      if (rw_props.length != 0) {
-        this.writable_personas[p.uid] = new HashMap<string, Field?> ();
-        foreach (var prop in rw_props)
-          add_edit_row (p, prop, ref i);
-      }
-
-      if (is_first_persona)
-        this.last_row = i - 1;
-
-      if (i != 3)
-        is_first_persona = false;
-
-      if (i == last_store_position) {
-        i--;
-        this.container_grid.get_child_at (0, i).destroy ();
+    foreach (var p in this.contact.individual.personas) {
+      foreach (var prop in p.writeable_properties) {
+        var field = add_edit_row (p, prop);
+        if (field != null)
+          add_field (field);
       }
     }
   }
 
   private void fill_in_empty () {
-    this.last_row = 2;
-
-    this.writable_personas["null-persona.hack"] = new HashMap<string, Field?> ();
     foreach (var prop in DEFAULT_PROPS_NEW_CONTACT) {
       var tok = prop.split (".");
-      add_new_row_for_property (null, tok[0], tok[1].up ());
-    }
-
-    this.focus_widget = this.name_entry;
-  }
-
-  Value get_value_from_emails (HashMap<int, RowData?> rows) {
-    var new_details = new HashSet<EmailFieldDetails>();
-
-    foreach (var row_entry in rows.entries) {
-      var combo = container_grid.get_child_at (0, row_entry.key) as TypeCombo;
-      var entry = container_grid.get_child_at (1, row_entry.key) as Entry;
-
-      /* Ignore empty entries. */
-      if (entry.get_text () == "")
-        continue;
-
-      combo.active_descriptor.save_to_field_details (row_entry.value.details);
-      var details = new EmailFieldDetails (entry.get_text (), row_entry.value.details.parameters);
-      new_details.add (details);
-    }
-    var new_value = Value (new_details.get_type ());
-    new_value.set_object (new_details);
-
-    return new_value;
-  }
-
-  Value get_value_from_phones (HashMap<int, RowData?> rows) {
-    var new_details = new HashSet<PhoneFieldDetails>();
-
-    foreach (var row_entry in rows.entries) {
-      var combo = container_grid.get_child_at (0, row_entry.key) as TypeCombo;
-      var entry = container_grid.get_child_at (1, row_entry.key) as Entry;
-
-      /* Ignore empty entries. */
-      if (entry.get_text () == "")
-        continue;
-
-      combo.active_descriptor.save_to_field_details (row_entry.value.details);
-      var details = new PhoneFieldDetails (entry.get_text (), row_entry.value.details.parameters);
-      new_details.add (details);
-    }
-    var new_value = Value (new_details.get_type ());
-    new_value.set_object (new_details);
-    return new_value;
-  }
-
-  Value get_value_from_urls (HashMap<int, RowData?> rows) {
-    var new_details = new HashSet<UrlFieldDetails>();
-
-    foreach (var row_entry in rows.entries) {
-      var entry = container_grid.get_child_at (1, row_entry.key) as Entry;
-
-      /* Ignore empty entries. */
-      if (entry.get_text () == "")
-        continue;
-
-      var details = new UrlFieldDetails (entry.get_text (), row_entry.value.details.parameters);
-      new_details.add (details);
-    }
-    var new_value = Value (new_details.get_type ());
-    new_value.set_object (new_details);
-    return new_value;
-  }
-
-  Value get_value_from_nickname (HashMap<int, RowData?> rows) {
-    var new_value = Value (typeof (string));
-    foreach (var row_entry in rows.entries) {
-      var entry = container_grid.get_child_at (1, row_entry.key) as Entry;
-
-      /* Ignore empty entries. */
-      if (entry.get_text () == "")
-        continue;
-
-      new_value.set_string (entry.get_text ());
-    }
-    return new_value;
-  }
-
-  Value get_value_from_birthday (HashMap<int, RowData?> rows) {
-    var new_value = Value (typeof (DateTime));
-    foreach (var row_entry in rows.entries) {
-      var box = container_grid.get_child_at (1, row_entry.key) as Grid;
-      var day_spin  = box.get_child_at (0, 0) as SpinButton;
-      var combo  = box.get_child_at (1, 0) as ComboBoxText;
-      var year_spin  = box.get_child_at (2, 0) as SpinButton;
-
-      var bday = new DateTime.local (year_spin.get_value_as_int (),
-                                    combo.get_active () + 1,
-                                    day_spin.get_value_as_int (),
-                                    0, 0, 0);
-      bday = bday.to_utc ();
-
-      new_value.set_boxed (bday);
-    }
-    return new_value;
-  }
-
-  Value get_value_from_notes (HashMap<int, RowData?> rows) {
-    var new_details = new HashSet<NoteFieldDetails>();
-
-    foreach (var row_entry in rows.entries) {
-      var text = (container_grid.get_child_at (1, row_entry.key) as Bin).get_child () as TextView;
-      TextIter start, end;
-      text.get_buffer ().get_start_iter (out start);
-      text.get_buffer ().get_end_iter (out end);
-      var value = text.get_buffer ().get_text (start, end, true);
-      if (value != "") {
-        var details = new NoteFieldDetails (value, row_entry.value.details.parameters);
-        new_details.add (details);
-      }
-    }
-    var new_value = Value (new_details.get_type ());
-    new_value.set_object (new_details);
-    return new_value;
-  }
-
-  Value get_value_from_addresses (HashMap<int, RowData?> rows) {
-    var new_details = new HashSet<PostalAddressFieldDetails>();
-
-    foreach (var row_entry in rows.entries) {
-      var combo = container_grid.get_child_at (0, row_entry.key) as TypeCombo;
-      var addr_editor = container_grid.get_child_at (1, row_entry.key) as AddressEditor;
-      combo.active_descriptor.save_to_field_details (row_entry.value.details);
-
-      var new_value = new PostalAddress (addr_editor.details.value.po_box,
-                                        addr_editor.details.value.extension,
-                                        addr_editor.details.value.street,
-                                        addr_editor.details.value.locality,
-                                        addr_editor.details.value.region,
-                                        addr_editor.details.value.postal_code,
-                                        addr_editor.details.value.country,
-                                        addr_editor.details.value.address_format,
-                                        addr_editor.details.id);
-      for (int i = 0; i < addr_editor.entries.length; i++)
-       new_value.set (AddressEditor.postal_element_props[i], addr_editor.entries[i].get_text ());
-
-      var details = new PostalAddressFieldDetails(new_value, row_entry.value.details.parameters);
-      new_details.add (details);
-    }
-    var new_value = Value (new_details.get_type ());
-    new_value.set_object (new_details);
-    return new_value;
-  }
-
-  void set_field_changed (int row) {
-    foreach (var fields in writable_personas.values) {
-      foreach (var entry in fields.entries) {
-       if (row in entry.value.rows.keys) {
-         if (entry.value.changed)
-           return;
-
-         entry.value.changed = true;
-         return;
-       }
-      }
-    }
-  }
-
-  new void remove_row (int row) {
-    foreach (var fields in writable_personas.values) {
-      foreach (var field_entry in fields.entries) {
-       foreach (var idx in field_entry.value.rows.keys) {
-         if (idx == row) {
-           var child = container_grid.get_child_at (0, row);
-           child.destroy ();
-           child = container_grid.get_child_at (1, row);
-           child.destroy ();
-           child = container_grid.get_child_at (3, row);
-           child.destroy ();
-
-           field_entry.value.changed = true;
-           field_entry.value.rows.unset (row);
-           return;
-         }
-       }
-      }
-    }
-  }
-
-  void attach_row_with_entry (int row, TypeSet type_set, AbstractFieldDetails details, string value, string? 
type = null) {
-    var combo = new TypeCombo (type_set);
-    combo.set_hexpand (false);
-    combo.set_active_from_field_details (details);
-    if (type != null)
-      combo.set_active_from_vcard_type (type);
-    combo.set_valign (Align.CENTER);
-    container_grid.attach (combo, 0, row, 1, 1);
-
-    var value_entry = new Entry ();
-    value_entry.set_text (value);
-    value_entry.set_hexpand (true);
-    container_grid.attach (value_entry, 1, row, 1, 1);
-
-    if (type_set == TypeSet.email) {
-      value_entry.placeholder_text = _("Add email");
-    } else if (type_set == TypeSet.phone) {
-      value_entry.placeholder_text = _("Add number");
+      var field = add_edit_row (null, tok[0], true, tok[1].up ());
+      if (field != null)
+        add_field (field);
     }
 
-    var delete_button = new Button.from_icon_name ("user-trash-symbolic", IconSize.MENU);
-    delete_button.get_accessible ().set_name (_("Delete field"));
-    container_grid.attach (delete_button, 3, row, 1, 1);
-
-    /* Notify change to upper layer */
-    combo.changed.connect ((c) => {
-       set_field_changed (get_current_row (combo));
-      });
-    value_entry.changed.connect (() => {
-       set_field_changed (get_current_row (value_entry));
-      });
-    delete_button.clicked.connect (() => {
-       remove_row (get_current_row (delete_button));
-      });
-
-    if (value == "")
-      focus_widget = value_entry;
-  }
-
-  void attach_row_with_entry_labeled (string title, AbstractFieldDetails? details, string value, int row) {
-    var title_label = new Label (title);
-    title_label.set_hexpand (false);
-    title_label.set_halign (Align.START);
-    title_label.margin_end = 6;
-    container_grid.attach (title_label, 0, row, 1, 1);
-
-    var value_entry = new Entry ();
-    value_entry.set_text (value);
-    value_entry.set_hexpand (true);
-    container_grid.attach (value_entry, 1, row, 1, 1);
-
-    var delete_button = new Button.from_icon_name ("user-trash-symbolic", IconSize.MENU);
-    delete_button.get_accessible ().set_name (_("Delete field"));
-    container_grid.attach (delete_button, 3, row, 1, 1);
-
-    /* Notify change to upper layer */
-    value_entry.changed.connect (() => {
-       set_field_changed (get_current_row (value_entry));
-      });
-    delete_button.clicked.connect_after (() => {
-       remove_row (get_current_row (delete_button));
-      });
-
-    if (value == "")
-      focus_widget = value_entry;
+    this.focus_widget = this.name_widget;
   }
 
-  void attach_row_with_text_labeled (string title, AbstractFieldDetails? details, string value, int row) {
-    var title_label = new Label (title);
-    title_label.set_hexpand (false);
-    title_label.set_halign (Align.START);
-    title_label.set_valign (Align.START);
-    title_label.margin_top = 3;
-    title_label.margin_end = 6;
-    container_grid.attach (title_label, 0, row, 1, 1);
-
-    var sw = new ScrolledWindow (null, null);
-    sw.set_shadow_type (ShadowType.OUT);
-    sw.set_size_request (-1, 100);
-    var value_text = new TextView ();
-    value_text.get_buffer ().set_text (value);
-    value_text.set_hexpand (true);
-    sw.add (value_text);
-    container_grid.attach (sw, 1, row, 1, 1);
-
-    var delete_button = new Button.from_icon_name ("user-trash-symbolic", IconSize.MENU);
-    delete_button.get_accessible ().set_name (_("Delete field"));
-    delete_button.set_valign (Align.START);
-    container_grid.attach (delete_button, 3, row, 1, 1);
-
-    /* Notify change to upper layer */
-    value_text.get_buffer ().changed.connect (() => {
-       set_field_changed (get_current_row (sw));
-      });
-    delete_button.clicked.connect (() => {
-       remove_row (get_current_row (delete_button));
-       /* eventually will need to check against the details type */
-       has_notes_row = false;
-      });
-
-    if (value == "")
-      focus_widget = value_text;
-  }
-
-  delegate void AdjustingDateFn();
-
-  void attach_row_for_birthday (string title, AbstractFieldDetails? details, DateTime birthday, int row) {
-    var title_label = new Label (title);
-    title_label.set_hexpand (false);
-    title_label.set_halign (Align.START);
-    title_label.margin_end = 6;
-    container_grid.attach (title_label, 0, row, 1, 1);
-
-    var box = new Grid ();
-    box.set_column_spacing (12);
-    var day_spin = new SpinButton.with_range (1.0, 31.0, 1.0);
-    day_spin.set_digits (0);
-    day_spin.numeric = true;
-    day_spin.set_value ((double)birthday.to_local ().get_day_of_month ());
-
-    var month_combo = new 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);
-        month_combo.append_text (month.format ("%B"));
-    }
-    month_combo.set_active (birthday.to_local ().get_month () - 1);
-    month_combo.hexpand = true;
-
-    var year_spin = new SpinButton.with_range (1800, 3000, 1);
-    year_spin.set_digits (0);
-    year_spin.numeric = true;
-    year_spin.set_value ((double)birthday.to_local ().get_year ());
-
-    box.add (day_spin);
-    box.add (month_combo);
-    box.add (year_spin);
-
-    container_grid.attach (box, 1, row, 1, 1);
-
-    var delete_button = new Button.from_icon_name ("user-trash-symbolic", IconSize.MENU);
-    delete_button.get_accessible ().set_name (_("Delete field"));
-    container_grid.attach (delete_button, 3, row, 1, 1);
-
-    AdjustingDateFn fn = () => {
-      int[] month_of_31 = {3, 5, 8, 10};
-      if (month_combo.get_active () in month_of_31) {
-        day_spin.set_range (1, 30);
-      } else if (month_combo.get_active () == 1) {
-        if (year_spin.get_value_as_int () % 4 == 0 &&
-            year_spin.get_value_as_int () % 100 != 0) {
-          day_spin.set_range (1, 29);
-        } else {
-          day_spin.set_range (1, 28);
-        }
-      }
-    };
-
-    /* Notify change to upper layer */
-    day_spin.changed.connect (() => {
-        set_field_changed (get_current_row (day_spin));
-      });
-    month_combo.changed.connect (() => {
-        set_field_changed (get_current_row (month_combo));
-
-        /* adjusting day_spin value using selected month constraints*/
-        fn ();
-      });
-    year_spin.changed.connect (() => {
-        set_field_changed (get_current_row (year_spin));
-
-        fn ();
-      });
-    delete_button.clicked.connect (() => {
-        remove_row (get_current_row (delete_button));
-        has_birthday_row = false;
-      });
-  }
-
-  void attach_row_for_address (int row, TypeSet type_set, PostalAddressFieldDetails details, string? type = 
null) {
-    var combo = new TypeCombo (type_set);
-    combo.set_hexpand (false);
-    combo.set_active_from_field_details (details);
-    if (type != null)
-      combo.set_active_from_vcard_type (type);
-    container_grid.attach (combo, 0, row, 1, 1);
-
-    var value_address = new AddressEditor (details);
-    container_grid.attach (value_address, 1, row, 1, 1);
-
-    var delete_button = new Button.from_icon_name ("user-trash-symbolic", IconSize.MENU);
-    delete_button.get_accessible ().set_name (_("Delete field"));
-    delete_button.set_valign (Align.START);
-    container_grid.attach (delete_button, 3, row, 1, 1);
-
-    /* Notify change to upper layer */
-    combo.changed.connect (() => {
-       set_field_changed (get_current_row (combo));
-      });
-    value_address.changed.connect (() => {
-       set_field_changed (get_current_row (value_address));
-      });
-    delete_button.clicked.connect (() => {
-       remove_row (get_current_row (delete_button));
-      });
-
-    focus_widget = value_address;
-  }
-
-  void add_edit_row (Persona? p, string prop_name, ref int row, bool add_empty = false, string? type = null) 
{
-    /* Here, we will need to add manually every type of field,
-     * we're planning to allow editing on */
-    string persona_uid = p != null ? p.uid : "null-persona.hack";
+  PropertyField? add_edit_row (Persona? p, string prop_name, bool add_empty = false, string? type = null) {
     switch (prop_name) {
     case "email-addresses":
-      var rows = new HashMap<int, RowData?> ();
-      if (add_empty) {
-       var detail_field = new EmailFieldDetails ("");
-       attach_row_with_entry (row, TypeSet.email, detail_field, "", type);
-       rows.set (row, { detail_field });
-       row++;
-      } else {
-       var details = p as EmailDetails;
-       if (details != null) {
-         var emails = Contact.sort_fields<EmailFieldDetails>(details.email_addresses);
-         foreach (var email in emails) {
-           attach_row_with_entry (row, TypeSet.email, email, email.value);
-           rows.set (row, { email });
-           row++;
-         }
-       }
-      }
-      if (! rows.is_empty) {
-       if (writable_personas[persona_uid].has_key (prop_name)) {
-         foreach (var entry in rows.entries) {
-           writable_personas[persona_uid][prop_name].rows.set (entry.key, entry.value);
-         }
-       } else {
-         writable_personas[persona_uid].set (prop_name, { false, rows });
-       }
-      }
+      if (add_empty)
+          return new EditableEmailsField.empty ();
+      if (EmailsField.should_show (p))
+        return new EditableEmailsField (p);
       break;
+
     case "phone-numbers":
-      var rows = new HashMap<int, RowData?> ();
-      if (add_empty) {
-       var detail_field = new PhoneFieldDetails ("");
-       attach_row_with_entry (row, TypeSet.phone, detail_field, "", type);
-       rows.set (row, { detail_field });
-       row++;
-      } else {
-       var details = p as PhoneDetails;
-       if (details != null) {
-         var phones = Contact.sort_fields<PhoneFieldDetails>(details.phone_numbers);
-         foreach (var phone in phones) {
-           attach_row_with_entry (row, TypeSet.phone, phone, phone.value, type);
-           rows.set (row, { phone });
-           row++;
-         }
-       }
-      }
-      if (! rows.is_empty) {
-       if (writable_personas[persona_uid].has_key (prop_name)) {
-         foreach (var entry in rows.entries) {
-           writable_personas[persona_uid][prop_name].rows.set (entry.key, entry.value);
-         }
-       } else {
-         writable_personas[persona_uid].set (prop_name, { false, rows });
-       }
-      }
+      if (add_empty)
+          return new EditablePhoneNrsField.empty ();
+      if (PhoneNrsField.should_show (p))
+        return new EditablePhoneNrsField (p);
       break;
+
     case "urls":
-      var rows = new HashMap<int, RowData?> ();
-      if (add_empty) {
-       var detail_field = new UrlFieldDetails ("");
-       attach_row_with_entry_labeled (_("Website"), detail_field, "", row);
-       rows.set (row, { detail_field });
-       row++;
-      } else {
-       var url_details = p as UrlDetails;
-       if (url_details != null) {
-         foreach (var url in url_details.urls) {
-           attach_row_with_entry_labeled (_("Website"), url, url.value, row);
-           rows.set (row, { url });
-           row++;
-         }
-       }
-      }
-      if (! rows.is_empty) {
-       if (writable_personas[persona_uid].has_key (prop_name)) {
-         foreach (var entry in rows.entries) {
-           writable_personas[persona_uid][prop_name].rows.set (entry.key, entry.value);
-         }
-       } else {
-         writable_personas[persona_uid].set (prop_name, { false, rows });
-       }
-      }
+      if (add_empty)
+        return new EditableUrlsField.empty ();
+      if (UrlsField.should_show (p))
+        return new EditableUrlsField (p);
       break;
+
     case "nickname":
-      var rows = new HashMap<int, RowData?> ();
-      if (add_empty) {
-       attach_row_with_entry_labeled (_("Nickname"), null, "", row);
-       rows.set (row, { null });
-       row++;
-      } else {
-       var name_details = p as NameDetails;
-       if (name_details != null) {
-         if (is_set (name_details.nickname)) {
-           attach_row_with_entry_labeled (_("Nickname"), null, name_details.nickname, row);
-           rows.set (row, { null });
-           row++;
-         }
-       }
-      }
-      if (! rows.is_empty) {
-       has_nickname_row = true;
-       var delete_button = container_grid.get_child_at (3, row - 1) as Button;
-       delete_button.clicked.connect (() => {
-           has_nickname_row = false;
-         });
-
-       if (writable_personas[persona_uid].has_key (prop_name)) {
-         foreach (var entry in rows.entries) {
-           writable_personas[persona_uid][prop_name].rows.set (entry.key, entry.value);
-         }
-       } else {
-         writable_personas[persona_uid].set (prop_name, { false, rows });
-       }
-      }
+      if (add_empty)
+        return new EditableNicknameField.empty ();
+      if (NicknameField.should_show (p))
+        return new EditableNicknameField (p);
       break;
+
     case "birthday":
-      var rows = new HashMap<int, RowData?> ();
-      if (add_empty) {
-       var today = new DateTime.now_local ();
-       attach_row_for_birthday (_("Birthday"), null, today, row);
-       rows.set (row, { null });
-       row++;
-      } else {
-       var birthday_details = p as BirthdayDetails;
-       if (birthday_details != null) {
-         if (birthday_details.birthday != null) {
-           attach_row_for_birthday (_("Birthday"), null, birthday_details.birthday, row);
-           rows.set (row, { null });
-           row++;
-         }
-       }
-      }
-      if (! rows.is_empty) {
-       has_birthday_row = true;
-       writable_personas[persona_uid].set (prop_name, { add_empty, rows });
-      }
+      if (add_empty)
+        return new EditableBirthdayField.empty ();
+      if (BirthdayField.should_show (p))
+        return new EditableBirthdayField (p);
       break;
+
     case "notes":
-      var rows = new HashMap<int, RowData?> ();
-      if (add_empty) {
-       var detail_field = new NoteFieldDetails ("");
-       attach_row_with_text_labeled (_("Note"), detail_field, "", row);
-       rows.set (row, { detail_field });
-       row++;
-      } else {
-       var note_details = p as NoteDetails;
-       if (note_details != null || add_empty) {
-         foreach (var note in note_details.notes) {
-           attach_row_with_text_labeled (_("Note"), note, note.value, row);
-           rows.set (row, { note });
-           row++;
-         }
-       }
-      }
-      if (! rows.is_empty) {
-       has_notes_row = true;
-       if (writable_personas[persona_uid].has_key (prop_name)) {
-         foreach (var entry in rows.entries) {
-           writable_personas[persona_uid][prop_name].rows.set (entry.key, entry.value);
-         }
-       } else {
-         writable_personas[persona_uid].set (prop_name, { false, rows });
-       }
-      }
+      if (add_empty)
+          return new EditableNotesField.empty ();
+      if (NotesField.should_show (p))
+        return new EditableNotesField (p);
       break;
+
     case "postal-addresses":
-      var rows = new HashMap<int, RowData?> ();
-      if (add_empty) {
-       var detail_field = new PostalAddressFieldDetails (
-                             new PostalAddress (null,
-                                               null,
-                                               null,
-                                               null,
-                                               null,
-                                               null,
-                                               null,
-                                               null,
-                                               null));
-       attach_row_for_address (row, TypeSet.general, detail_field, type);
-       rows.set (row, { detail_field });
-       row++;
-      } else {
-       var address_details = p as PostalAddressDetails;
-       if (address_details != null) {
-         foreach (var addr in address_details.postal_addresses) {
-           attach_row_for_address (row, TypeSet.general, addr, type);
-           rows.set (row, { addr });
-           row++;
-         }
-       }
-      }
-      if (! rows.is_empty) {
-       if (writable_personas[persona_uid].has_key (prop_name)) {
-         foreach (var entry in rows.entries) {
-           writable_personas[persona_uid][prop_name].rows.set (entry.key, entry.value);
-         }
-       } else {
-         writable_personas[persona_uid].set (prop_name, { false, rows });
-       }
-      }
+      if (add_empty)
+        return new EditablePostalAddressesField.empty ();
+      if (PostalAddressesField.should_show (p))
+        return new EditablePostalAddressesField (p);
       break;
     }
-  }
 
-  int get_current_row (Widget child) {
-    int row;
-
-    container_grid.child_get (child, "top-attach", out row);
-    return row;
-  }
-
-  void insert_row_at (int idx) {
-    foreach (var field_maps in writable_personas.values) {
-      foreach (var field in field_maps.values) {
-       foreach (var row in field.rows.keys) {
-         if (row >= idx) {
-           var new_rows = new HashMap <int, RowData?> ();
-           foreach (var old_row in field.rows.keys) {
-             /* move all rows +1 */
-             new_rows.set (old_row + 1, field.rows[old_row]);
-           }
-           field.rows = new_rows;
-           break;
-         }
-       }
-      }
-    }
-    foreach (var entry in writable_personas.entries) {
-      foreach (var field_entry in entry.value.entries) {
-       foreach (var row in field_entry.value.rows.keys) {
-         if (row >= idx) {
-           var new_rows = new HashMap <int, RowData?> ();
-           foreach (var old_row in field_entry.value.rows.keys) {
-             new_rows.set (old_row + 1, field_entry.value.rows[old_row]);
-           }
-           field_entry.value.rows = new_rows;
-           break;
-         }
-       }
-      }
-    }
-    container_grid.insert_row (idx);
+    return null;
   }
 
   private void on_container_grid_size_allocate (Allocation alloc) {
@@ -828,97 +166,67 @@ public class Contacts.ContactEditor : ContactForm {
     }
   }
 
-  public HashMap<string, PropertyData?> properties_changed () {
-    var props_set = new HashMap<string, PropertyData?> ();
-
-    foreach (var entry in writable_personas.entries) {
-      foreach (var field_entry in entry.value.entries) {
-       if (field_entry.value.changed && !props_set.has_key (field_entry.key)) {
-         PropertyData p = PropertyData ();
-         p.persona = null;
-         if (contact != null) {
-           p.persona = contact.find_persona_from_uid (entry.key);
-         }
-
-         switch (field_entry.key) {
-           case "email-addresses":
-             p.value = get_value_from_emails (field_entry.value.rows);
-             break;
-           case "phone-numbers":
-             p.value = get_value_from_phones (field_entry.value.rows);
-             break;
-           case "urls":
-             p.value = get_value_from_urls (field_entry.value.rows);
-             break;
-           case "nickname":
-             p.value = get_value_from_nickname (field_entry.value.rows);
-             break;
-           case "birthday":
-             p.value = get_value_from_birthday (field_entry.value.rows);
-             break;
-           case "notes":
-             p.value = get_value_from_notes (field_entry.value.rows);
-             break;
-            case "postal-addresses":
-             p.value = get_value_from_addresses (field_entry.value.rows);
-             break;
-         }
-
-         props_set.set (field_entry.key, p);
-       }
-      }
+  public async void save_changes () throws Error {
+    for (uint i = 0; i < this.fields.get_n_items (); i++) {
+      var field = this.fields.get_item (i) as EditableProperty;
+      if (field != null)
+        yield field.save_changes ();
     }
 
-    return props_set;
-  }
+    if (name_changed ()) {
+      var v = get_full_name_value ();
+      yield this.contact.set_individual_property ("full-name", v);
+      /*XXX*/
+      /* display_name_changed (v.get_string ()); */
+    }
 
-  public void add_new_row_for_property (Persona? p, string prop_name, string? type = null) {
-    /* Somehow, I need to ensure that p is the main/default/first persona */
-    Persona persona = null;
-    if (contact != null) {
-      if (p == null) {
-        persona = new FakePersona (this.store, contact);
-        writable_personas[persona.uid] = new HashMap<string, Field?> ();
-      } else {
-        persona = p;
-      }
+    if (avatar_changed ()) {
+      var v = get_avatar_value ();
+      yield this.contact.set_individual_property ("avatar", v);
     }
+  }
 
-    int next_idx = 0;
-    foreach (var fields in writable_personas.values) {
-      if (fields.has_key (prop_name)) {
-         foreach (var idx in fields[prop_name].rows.keys) {
-           if (idx < last_row)
-             next_idx = idx > next_idx ? idx : next_idx;
-         }
-         break;
-      }
+  public HashTable<string, Value?> create_details_for_new_contact () {
+    var details = new HashTable<string, Value?> (str_hash, str_equal);
+
+    // Collect the details from the editor
+    if (name_changed ())
+      details["full-name"] = get_full_name_value ();
+
+    if (avatar_changed ())
+      details["avatar"] = get_avatar_value ();
+
+    for (uint i = 0; i < this.fields.get_n_items (); i++) {
+      var field = this.fields.get_item (i) as EditableProperty;
+
+      if (field != null)
+        details[field.property_name] = field.create_value ();
     }
-    next_idx = (next_idx == 0 ? last_row : next_idx) + 1;
-    insert_row_at (next_idx);
-    add_edit_row (persona, prop_name, ref next_idx, true, type);
-    last_row++;
-    container_grid.show_all ();
+
+    return details;
+  }
+
+  public void add_new_row_for_property (Persona? p, string prop_name, string? type = null) {
   }
 
   // Creates the contact's current avatar in a big button on top of the Editor
   private void create_avatar_button () {
-    this.avatar = new Avatar (PROFILE_SIZE, this.contact);
+    this.avatar_widget = new Avatar (PROFILE_SIZE, this.contact);
 
     var button = new Button ();
     button.get_accessible ().set_name (_("Change avatar"));
-    button.image = this.avatar;
+    button.image = (Avatar) this.avatar_widget;
     button.clicked.connect (on_avatar_button_clicked);
 
-    this.container_grid.attach (button, 0, 0, 1, 3);
+    attach_avatar_widget (button);
   }
 
   // Show the avatar popover when the avatar is clicked
   private void on_avatar_button_clicked (Button avatar_button) {
     var popover = new AvatarSelector (avatar_button, this.contact);
     popover.set_avatar.connect ( (icon) =>  {
-        this.avatar.set_data ("value", icon);
-        this.avatar.set_data ("changed", true);
+        this.avatar_widget.set_data ("value", icon);
+        this.avatar_widget.set_data ("changed", true);
 
         Gdk.Pixbuf? a_pixbuf = null;
         try {
@@ -927,17 +235,17 @@ public class Contacts.ContactEditor : ContactForm {
         } catch {
         }
 
-        this.avatar.set_pixbuf (a_pixbuf);
+        ((Avatar) this.avatar_widget).set_pixbuf (a_pixbuf);
       });
     popover.show();
   }
 
   public bool avatar_changed () {
-    return this.avatar.get_data<bool> ("changed");
+    return this.avatar_widget.get_data<bool> ("changed");
   }
 
   public Value get_avatar_value () {
-    GLib.Icon icon = this.avatar.get_data<GLib.Icon> ("value");
+    GLib.Icon icon = this.avatar_widget.get_data<GLib.Icon> ("value");
     Value v = Value (icon.get_type ());
     v.set_object (icon);
     return v;
@@ -945,30 +253,27 @@ public class Contacts.ContactEditor : ContactForm {
 
   // Creates the big name entry on the top
   private void create_name_entry () {
-    this.name_entry = new Entry ();
-    this.name_entry.hexpand = true;
-    this.name_entry.valign = Align.CENTER;
-    this.name_entry.placeholder_text = _("Add name");
-    this.name_entry.set_data ("changed", false);
+    var name_entry = new Entry ();
+    name_entry.placeholder_text = _("Add name");
+    name_entry.set_data ("changed", false);
+    set_name_widget (name_entry);
 
     if (this.contact != null)
-        this.name_entry.text = this.contact.individual.display_name;
+        name_entry.text = this.contact.individual.display_name;
 
     /* structured name change */
-    this.name_entry.changed.connect (() => {
-        this.name_entry.set_data ("changed", true);
+    name_entry.changed.connect (() => {
+        name_entry.set_data ("changed", true);
       });
-
-    this.container_grid.attach (this.name_entry, 1, 0, 3, 3);
   }
 
   public bool name_changed () {
-    return this.name_entry.get_data<bool> ("changed");
+    return this.name_widget.get_data<bool> ("changed");
   }
 
   public Value get_full_name_value () {
     Value v = Value (typeof (string));
-    v.set_string (this.name_entry.get_text ());
+    v.set_string (((Entry) this.name_widget).text);
     return v;
   }
 }
diff --git a/src/contacts-contact-form-field.vala b/src/contacts-contact-form-field.vala
new file mode 100644
index 0000000..6419a21
--- /dev/null
+++ b/src/contacts-contact-form-field.vala
@@ -0,0 +1,975 @@
+/*
+ * Copyright (C) 2018 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 Gtk;
+using Folks;
+using Gee;
+
+/**
+ * A PropertyField is an abstraction of the property of a {@link Folks.Persona}.
+ */
+public abstract class Contacts.PropertyField : Object {
+
+  /**
+   * The {@link Folks.Persona} this property belongs to.
+   */
+  public Persona? persona { get; construct set; default = null; }
+
+  /**
+   * The canonical name of this property.
+   */
+  public string property_name { get; construct set; }
+
+// XXX Do we really need to save this?
+  protected PropertyWidget? row { get; private set; }
+
+  /**
+   * Each subclass is responsible for creating the necessary UI and
+   * incorporating it into the given {@link PropertyWidget}.
+   */
+  protected abstract void create_widgets (PropertyWidget widget);
+
+  /**
+   * Creates a widget that can show the property inside a {@link Gtk.ListBox}.
+   */
+  public ListBoxRow create_row (SizeGroup label_group,
+                                SizeGroup value_group,
+                                SizeGroup actions_group) {
+    this.row = new PropertyWidget (this, label_group, value_group, actions_group);
+    this.row.margin_top = 18;
+    this.row.hexpand = true;
+
+    // The subclass is responsible to make the appropriate widgets
+    create_widgets (row);
+
+    this.row.show_all ();
+
+    return this.row;
+  }
+}
+
+public interface Contacts.EditableProperty : PropertyField {
+
+  /* public bool dirty { get; construct set; } */
+
+  /**
+   * Creates a new {@link GLib.Value} from the content of this property.
+   * This method is used when a new contact is created.
+   */
+  public abstract Value? create_value ();
+
+  /**
+   * Saves the content of this property to the {@link Folks.Persona}. Note that
+   * it is a programmer error to call this when `this.persona == null`.
+   */
+  public abstract async void save_changes () throws PropertyError;
+}
+
+public class Contacts.PropertyWidget : ListBoxRow {
+
+  private Grid grid = new Grid ();
+
+  private unowned SizeGroup labels_group;
+  private unowned SizeGroup values_group;
+  private unowned SizeGroup actions_group;
+
+// The parent field
+/// XXX maybe only store the persona?
+  public weak PropertyField field { get; construct set; }
+
+  construct {
+    this.selectable = false;
+    this.activatable = false;
+
+    this.grid.column_spacing = 12;
+    this.grid.row_spacing = 18;
+    this.grid.hexpand = true;
+    add (this.grid);
+  }
+
+  public PropertyWidget (PropertyField parent, SizeGroup labels, SizeGroup values, SizeGroup actions) {
+    Object (field: parent);
+
+    this.labels_group = labels;
+    this.values_group = values;
+    this.actions_group = actions;
+  }
+
+  // Get the latest row number. This might have changed due to e.g. deletion of some row
+  private int get_last_row_nr () {
+    int last_row = -1;
+    foreach (var child in this.grid.get_children ())
+      last_row = int.max (last_row, get_child_row (child));
+
+    return last_row;
+  }
+
+  // Returns the top-attach child property or -1 if not a child
+  public int get_child_row (Widget child) {
+    int top_attach = -1;
+    this.grid.child_get (child, "top-attach", out top_attach);
+    return top_attach;
+  }
+
+  public void add_row (Widget label, Widget value, Widget? actions = null) {
+    int row_nr = get_last_row_nr () + 1;
+    this.grid.attach (label, 0, row_nr);
+    this.grid.attach (value, 1, row_nr);
+    if (actions != null)
+      this.grid.attach (actions, 2, row_nr);
+
+    this.labels_group.add_widget (label);
+    this.values_group.add_widget (value);
+    if (actions != null)
+      this.actions_group.add_widget (actions);
+  }
+
+  // Buidler
+  // Up next are some
+  public Label create_type_label (string? text) {
+    var label = new Label (text ?? "");
+
+    label.xalign = 1.0f;
+    label.halign = Align.END;
+    label.valign = Align.START;
+    label.get_style_context ().add_class ("dim-label");
+
+    return label;
+  }
+
+  public TypeCombo create_type_combo (TypeSet typeset, TypeDescriptor initial_type) {
+    var combo = new TypeCombo (typeset);
+    combo.active_descriptor = initial_type;
+    combo.valign = Align.START;
+    return combo;
+  }
+
+  public Label create_value_label (string? text, bool use_markup = false) {
+    var label = new Label (text ?? "");
+    label.use_markup = use_markup;
+    label.set_line_wrap (true);
+    label.xalign = 0.0f;
+    label.set_halign (Align.START);
+    label.set_ellipsize (Pango.EllipsizeMode.END);
+    label.wrap_mode = Pango.WrapMode.CHAR;
+    label.set_selectable (true);
+
+    return label;
+  }
+
+  public Label create_value_link (string text, string url) {
+    var link = "<a href=\"%s\">%s</a>".printf (url, text);
+    return create_value_label (link, true);
+  }
+
+  public Entry create_value_entry (string? text) {
+    var value_entry = new Entry ();
+    value_entry.text = text;
+    value_entry.hexpand = true;
+
+    return value_entry;
+  }
+
+  public Widget create_value_textview (string? text) {
+    var sw = new ScrolledWindow (null, null);
+    sw.shadow_type = ShadowType.OUT;
+    sw.set_size_request (-1, 100);
+
+    var value_text = new TextView ();
+    value_text.buffer.text = text;
+    value_text.hexpand = true;
+    sw.add (value_text);
+
+    return sw;
+  }
+
+  public Button create_delete_button (string? description) {
+    var delete_button = new Button.from_icon_name ("user-trash-symbolic", IconSize.MENU);
+    delete_button.valign = Align.START;
+    delete_button.get_accessible ().set_name (description);
+    delete_button.clicked.connect ((button) => {
+        int top_attach;
+        this.grid.child_get (delete_button, "top-attach", out top_attach);
+        this.grid.remove_row (top_attach);
+      });
+    return delete_button;
+  }
+}
+
+public class Contacts.NicknameField : PropertyField {
+
+  protected string nickname = "";
+
+  public NicknameField (Persona persona) {
+    Object (
+      property_name: "nickname",
+      persona: persona
+    );
+
+    this.nickname = ((NameDetails) persona).nickname;
+  }
+
+  public static bool should_show (Persona persona) {
+    unowned NameDetails? details = persona as NameDetails;
+    return (details != null && details.nickname != "");
+  }
+
+  protected override void create_widgets (PropertyWidget widget) {
+    var type_label = row.create_type_label (_("Nickname"));
+    var value_label = row.create_value_label (this.nickname);
+    widget.add_row (type_label, value_label);
+  }
+}
+
+public class Contacts.EditableNicknameField : NicknameField, EditableProperty {
+
+  public EditableNicknameField (Persona persona) {
+    base (persona);
+  }
+
+  public EditableNicknameField.empty () {
+    Object (
+      property_name: "nickname",
+      persona: null
+    );
+  }
+
+  protected override void create_widgets (PropertyWidget widget) {
+    var type_label = row.create_type_label (_("Nickname"));
+    var nickname_entry = row.create_value_entry (this.nickname);
+    nickname_entry.changed.connect ((editable) => {
+        this.nickname = editable.get_chars ();
+      });
+
+    var delete_button = row.create_delete_button (_("Remove nickname"));
+    widget.add_row (type_label, nickname_entry, delete_button);
+  }
+
+  public Value? create_value () {
+    if (this.nickname == "")
+      return null;
+
+    var new_value = Value (typeof (string));
+    new_value.set_string (this.nickname);
+    return new_value;
+  }
+
+  public async void save_changes () throws PropertyError {
+    assert (this.persona != null);
+
+    yield ((NameDetails) this.persona).change_nickname (this.nickname);
+  }
+}
+
+public class Contacts.BirthdayField : PropertyField {
+
+  // In local timezone
+  protected DateTime birthday;
+
+  public BirthdayField (Persona persona) {
+    Object (
+      property_name: "birthday",
+      persona: persona
+    );
+
+    unowned BirthdayDetails details = (BirthdayDetails) persona;
+    this.birthday = details.birthday.to_local ();
+  }
+
+  public static bool should_show (Persona persona) {
+    unowned BirthdayDetails? details = persona as BirthdayDetails;
+    return (details != null && details.birthday != null);
+  }
+
+  protected override void create_widgets (PropertyWidget widget) {
+    var type_label = row.create_type_label (_("Birthday"));
+    var value_label = row.create_value_label (this.birthday.format ("%x"));
+    widget.add_row (type_label, value_label);
+  }
+}
+
+public class Contacts.EditableBirthdayField : BirthdayField, EditableProperty {
+
+  public EditableBirthdayField (Persona persona) {
+    base (persona);
+  }
+
+  public EditableBirthdayField.empty () {
+    Object (
+      property_name: "birthday",
+      persona: null
+    );
+
+    this.birthday = new DateTime.now_local ();
+  }
+
+  protected override void create_widgets (PropertyWidget widget) {
+    var type_label = row.create_type_label (_("Birthday"));
+    var birthday_entry = create_date_widget ();
+    var delete_button = row.create_delete_button (_("Remove birthday"));
+    widget.add_row (type_label, birthday_entry, delete_button);
+  }
+
+  private Widget create_date_widget () {
+    var box = new Grid ();
+    box.column_spacing = 12;
+
+    // Day
+    var day_spin = new SpinButton.with_range (1.0, 31.0, 1.0);
+    day_spin.set_digits (0);
+    day_spin.numeric = true;
+    day_spin.set_value (this.birthday.get_day_of_month ());
+    box.add (day_spin);
+
+    // Month
+    var month_combo = new 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);
+        month_combo.append_text (month.format ("%B"));
+    }
+    month_combo.set_active (this.birthday.get_month () - 1);
+    month_combo.hexpand = true;
+    box.add (month_combo);
+
+    // Year
+    var year_spin = new SpinButton.with_range (1800, 3000, 1);
+    year_spin.set_digits (0);
+    year_spin.numeric = true;
+    year_spin.set_value (this.birthday.get_year ());
+    box.add (year_spin);
+
+    // We can't set the day/month/year directly, so calculate the diff and add that
+    day_spin.changed.connect (() => {
+        var diff = day_spin.get_value_as_int () - this.birthday.get_day_of_month ();
+        this.birthday = this.birthday.add_days (diff);
+      });
+    month_combo.changed.connect (() => {
+        adjust_date_range (year_spin, month_combo, day_spin);
+
+        var diff = (month_combo.get_active () + 1) - this.birthday.get_month ();
+        this.birthday = this.birthday.add_months (diff);
+      });
+    year_spin.changed.connect (() => {
+        adjust_date_range (year_spin, month_combo, day_spin);
+
+        var diff = year_spin.get_value_as_int () - this.birthday.get_year ();
+        this.birthday = this.birthday.add_years (diff);
+      });
+
+    return box;
+  }
+
+  // Make sure our user can't make an invalid date (e.g. February 31)
+  private void adjust_date_range (SpinButton year_spin, ComboBoxText month_combo, SpinButton day_spin) {
+    const int[] month_of_31 = {3, 5, 8, 10};
+    if (month_combo.get_active () in month_of_31) {
+      day_spin.set_range (1, 30);
+    } else if (month_combo.get_active () == 1) {
+      var year = (DateYear) year_spin.get_value_as_int ();
+      var nr_days = year.is_leap_year ()? 29 : 28;
+      day_spin.set_range (1, nr_days);
+    }
+  }
+
+  public Value? create_value () {
+    var new_value = Value (typeof (DateTime));
+    new_value.set_boxed (this.birthday.to_utc ());
+    return new_value;
+  }
+
+  public async void save_changes () throws PropertyError {
+    assert (this.persona != null);
+
+    yield ((BirthdayDetails) this.persona).change_birthday (this.birthday);
+  }
+}
+
+public class Contacts.PhoneNrsField : PropertyField {
+
+  protected ArrayList<TypeDescriptor> types = new ArrayList<TypeDescriptor> ();
+  protected ArrayList<string> phone_nrs = new ArrayList<string> ();
+  protected ArrayList<unowned MultiMap<string, string>?> parameters
+      = new ArrayList<unowned MultiMap<string, string>?> ();
+
+  public PhoneNrsField (Persona persona) {
+    Object (
+      property_name: "phone-numbers",
+      persona: persona
+    );
+
+    unowned PhoneDetails? details = this.persona as PhoneDetails;
+    foreach (var phone in details.phone_numbers) {
+      var type_descr = TypeSet.phone.lookup_descriptor_for_field_details (phone);
+      this.types.add (type_descr);
+      this.phone_nrs.add (phone.value);
+      this.parameters.add (phone.parameters);
+    }
+  }
+
+  public static bool should_show (Persona persona) {
+    unowned PhoneDetails? details = persona as PhoneDetails;
+    return (details != null && !details.phone_numbers.is_empty);
+  }
+
+  protected override void create_widgets (PropertyWidget widget) {
+    for (int i = 0; i < this.phone_nrs.size; i++)
+      add_field (this.types[i], this.phone_nrs[i], widget);
+  }
+
+  protected virtual void add_field (TypeDescriptor type, string phone, PropertyWidget widget) {
+    var type_label = row.create_type_label (type.display_name);
+    var value_label = row.create_value_label (phone);
+    widget.add_row (type_label, value_label);
+  }
+}
+
+public class Contacts.EditablePhoneNrsField : PhoneNrsField, EditableProperty {
+
+  public EditablePhoneNrsField (Persona persona) {
+    base (persona);
+  }
+
+  public EditablePhoneNrsField.empty () {
+    Object (
+      property_name: "phone-numbers",
+      persona: null
+    );
+
+    this.types.add (TypeSet.phone.lookup_descriptor_in_store ("Mobile"));
+    this.phone_nrs.add ("");
+    this.parameters.add (null);
+  }
+
+  protected override void add_field (TypeDescriptor type, string phone, PropertyWidget widget) {
+    var type_combo = row.create_type_combo (TypeSet.phone, type);
+    type_combo.changed.connect ((combo) => {
+        var row_nr = row.get_child_row (combo);
+        this.types[row_nr] = type_combo.active_descriptor;
+      });
+
+    var entry = row.create_value_entry (phone);
+    entry.changed.connect ((editable) => {
+        var row_nr = row.get_child_row ((Widget) editable);
+        this.phone_nrs[row_nr] = editable.get_chars ();
+      });
+
+    var delete_button = row.create_delete_button (_("Remove phone number"));
+
+    /* widget.add_row (type_label, entry, delete_button); */
+    widget.add_row (type_combo, entry, delete_button);
+  }
+
+  public Value? create_value () {
+    if (this.phone_nrs.is_empty)
+      return null;
+
+    var new_details = create_set ();
+    // Check if we only had empty phone_nrs
+    if (new_details.is_empty)
+      return null;
+
+    var result = Value (new_details.get_type ());
+    result.set_object (new_details);
+    return result;
+  }
+
+  public async void save_changes () throws PropertyError {
+    assert (this.persona != null);
+
+    var new_addrs = create_set ();
+    yield ((PhoneDetails) this.persona).change_phone_numbers (new_addrs);
+  }
+
+  private HashSet<PhoneFieldDetails>? create_set () {
+    var new_details = new HashSet<PhoneFieldDetails> ();
+    for (int i = 0; i < this.phone_nrs.size; i++) {
+      if (this.phone_nrs[i] == "")
+        continue;
+
+      var parameters = this.types[i].add_type_to_parameters (this.parameters[i]);
+      var phone = new PhoneFieldDetails (this.phone_nrs[i], parameters);
+      new_details.add (phone);
+    }
+
+    return new_details;
+  }
+}
+
+public class Contacts.EmailsField : PropertyField {
+
+  protected ArrayList<TypeDescriptor> types = new ArrayList<TypeDescriptor> ();
+  protected ArrayList<string> emails = new ArrayList<string> ();
+  protected ArrayList<unowned MultiMap<string, string>?> parameters
+      = new ArrayList<unowned MultiMap<string, string>?> ();
+
+  public EmailsField (Persona persona) {
+    Object (
+      property_name: "email-addresses",
+      persona: persona
+    );
+
+    unowned EmailDetails? details = persona as EmailDetails;
+    foreach (var email in details.email_addresses) {
+      var type_descr = TypeSet.email.lookup_descriptor_for_field_details (email);
+      this.types.add (type_descr);
+      this.emails.add (email.value);
+      this.parameters.add (email.parameters);
+    }
+  }
+
+  public static bool should_show (Persona persona) {
+    unowned EmailDetails? details = persona as EmailDetails;
+    return (details != null && !details.email_addresses.is_empty);
+  }
+
+  protected override void create_widgets (PropertyWidget widget) {
+    for (int i = 0; i < this.emails.size; i++)
+      add_field (this.types[i], this.emails[i], widget);
+  }
+
+  protected virtual void add_field (TypeDescriptor type, string email, PropertyWidget widget) {
+    var type_label = row.create_type_label (type.display_name);
+    var url = "mailto:"; + Uri.escape_string (email, "@", false);
+    var value_label = row.create_value_link (email, url);
+    widget.add_row (type_label, value_label);
+  }
+}
+
+public class Contacts.EditableEmailsField : EmailsField, EditableProperty {
+
+  public EditableEmailsField (Persona persona) {
+    base (persona);
+  }
+
+  public EditableEmailsField.empty () {
+    Object (
+      property_name: "email-addresses",
+      persona: null
+    );
+
+    this.types.add (TypeSet.email.lookup_descriptor_in_store ("Personal"));
+    this.emails.add ("");
+    this.parameters.add (null);
+  }
+
+  protected override void add_field (TypeDescriptor type, string email, PropertyWidget widget) {
+    var type_combo = row.create_type_combo (TypeSet.email, type);
+    type_combo.changed.connect ((combo) => {
+        var row_nr = row.get_child_row (combo);
+        this.types[row_nr] = type_combo.active_descriptor;
+      });
+
+    var entry = row.create_value_entry (email);
+    entry.changed.connect ((editable) => {
+        var row_nr = row.get_child_row ((Widget) editable);
+        this.emails[row_nr] = editable.get_chars ();
+      });
+    var delete_button = row.create_delete_button (_("Remove email address"));
+
+    widget.add_row (type_combo, entry, delete_button);
+  }
+
+  public Value? create_value () {
+    if (this.emails.is_empty)
+      return null;
+
+    var new_details = create_set ();
+    // Check if we only had empty emails
+    if (new_details.is_empty)
+      return null;
+
+    var result = Value (new_details.get_type ());
+    result.set_object (new_details);
+    return result;
+  }
+
+  public async void save_changes () throws PropertyError {
+    assert (this.persona != null);
+
+    var new_addrs = create_set ();
+    yield ((EmailDetails) this.persona).change_email_addresses (new_addrs);
+  }
+
+  private HashSet<EmailFieldDetails>? create_set () {
+    var new_details = new HashSet<EmailFieldDetails> ();
+    for (int i = 0; i < this.emails.size; i++) {
+      if (this.emails[i] != "")
+        continue;
+
+      var parameters = this.types[i].add_type_to_parameters (this.parameters[i]);
+      var email = new EmailFieldDetails (this.emails[i], parameters);
+      new_details.add (email);
+    }
+
+    return new_details;
+  }
+}
+
+public class Contacts.UrlsField : PropertyField {
+
+  protected ArrayList<string> urls = new ArrayList<string> ();
+  protected ArrayList<unowned MultiMap<string, string>?> parameters
+      = new ArrayList<unowned MultiMap<string, string>?> ();
+
+  public UrlsField (Persona persona) {
+    Object (
+      property_name: "urls",
+      persona: persona
+    );
+
+    unowned UrlDetails? details = persona as UrlDetails;
+    foreach (var url in details.urls) {
+      this.urls.add (url.value);
+      this.parameters.add (url.parameters);
+    }
+  }
+
+  public static bool should_show (Persona persona) {
+    unowned UrlDetails? details = persona as UrlDetails;
+    return (details != null && !details.urls.is_empty);
+  }
+
+  protected override void create_widgets (PropertyWidget widget) {
+    for (int i = 0; i < urls.size; i++)
+      add_field (this.urls[i], widget);
+  }
+
+  protected virtual void add_field (string url, PropertyWidget widget) {
+    var type_label = row.create_type_label (_("Website"));
+    var url_link = Uri.escape_string (url, "@", false);
+    var value_label = row.create_value_link (url, url_link);
+
+    widget.add_row (type_label, value_label);
+  }
+}
+
+public class Contacts.EditableUrlsField : UrlsField, EditableProperty {
+
+  public EditableUrlsField (Persona persona) {
+    base (persona);
+  }
+
+  public EditableUrlsField.empty () {
+    Object (
+      property_name: "urls",
+      persona: null
+    );
+
+    this.urls.add ("");
+    this.parameters.add (null);
+  }
+
+  protected override void add_field (string url, PropertyWidget widget) {
+    var type_label = row.create_type_label (_("Website"));
+    var entry = row.create_value_entry (url);
+    entry.changed.connect ((editable) => {
+        var row_nr = row.get_child_row ((Widget) editable);
+        this.urls[row_nr] = editable.get_chars ();
+      });
+    var delete_button = row.create_delete_button (_("Remove website"));
+
+    widget.add_row (type_label, entry, delete_button);
+  }
+
+  public Value? create_value () {
+    if (this.urls.is_empty)
+      return null;
+
+    var new_details = create_set ();
+    // Check if we only had empty urls
+    if (new_details.is_empty)
+      return null;
+
+    var result = Value (new_details.get_type ());
+    result.set_object (new_details);
+    return result;
+  }
+
+  public async void save_changes () throws PropertyError {
+    assert (this.persona != null);
+
+    var new_urls = create_set ();
+    yield ((UrlDetails) this.persona).change_urls (new_urls);
+  }
+
+  private HashSet<UrlFieldDetails>? create_set () {
+    var new_details = new HashSet<UrlFieldDetails> ();
+    for (int i = 0; i < this.urls.size; i++) {
+      if (this.urls[i] == "")
+        continue;
+
+      var url = new UrlFieldDetails (this.urls[i], this.parameters[i]);
+      new_details.add (url);
+    }
+
+    return new_details;
+  }
+}
+
+public class Contacts.NotesField : PropertyField {
+
+  protected ArrayList<string> notes = new ArrayList<string> ();
+  protected ArrayList<unowned MultiMap<string, string>?> parameters
+      = new ArrayList<unowned MultiMap<string, string>?> ();
+
+  public NotesField (Persona persona) {
+    Object (
+      property_name: "notes",
+      persona: persona
+    );
+
+    unowned NoteDetails? details = persona as NoteDetails;
+    foreach (var note in details.notes) {
+      this.notes.add (note.value);
+      this.parameters.add (note.parameters);
+    }
+  }
+
+  public static bool should_show (Persona persona) {
+    unowned NoteDetails? details = persona as NoteDetails;
+    return (details != null && !details.notes.is_empty);
+  }
+
+  protected override void create_widgets (PropertyWidget widget) {
+    for (int i = 0; i < notes.size; i++)
+      add_field (this.notes[i], widget);
+  }
+
+  protected virtual void add_field (string note, PropertyWidget widget) {
+    var type_label = row.create_type_label (_("Note"));
+    var value_label = row.create_value_label (note);
+    widget.add_row (type_label, value_label);
+  }
+}
+
+public class Contacts.EditableNotesField : NotesField, EditableProperty {
+
+  public EditableNotesField (Persona persona) {
+    base (persona);
+  }
+
+  public EditableNotesField.empty () {
+    Object (
+      property_name: "note-addresses",
+      persona: null
+    );
+
+    this.notes.add ("");
+    this.parameters.add (null);
+  }
+
+  protected override void add_field (string note, PropertyWidget widget) {
+    var type_label = row.create_type_label (_("Note"));
+    var textview_container = row.create_value_textview (note);
+    /* XXX entry.changed.connect ((editable) => { */
+    /*     var row_nr = row.get_child_row ((Widget) editable); */
+    /*     this.urls[row_nr] = editable.get_chars (); */
+    /*   }); */
+    var delete_button = row.create_delete_button (_("Remove note"));
+
+    widget.add_row (type_label, textview_container, delete_button);
+  }
+
+  public Value? create_value () {
+    if (this.notes.is_empty)
+      return null;
+
+    var new_details = create_set ();
+    // Check if we only had empty addresses
+    if (new_details.is_empty)
+      return null;
+
+    var result = Value (new_details.get_type ());
+    result.set_object (new_details);
+    return result;
+  }
+
+  public async void save_changes () throws PropertyError {
+    assert (this.persona != null);
+
+    var new_addrs = create_set ();
+    yield ((NoteDetails) this.persona).change_notes (new_addrs);
+  }
+
+  private HashSet<NoteFieldDetails>? create_set () {
+    var new_details = new HashSet<NoteFieldDetails> ();
+    for (int i = 0; i < this.notes.size; i++) {
+      if (this.notes[i] == "")
+        continue;
+
+      var note = new NoteFieldDetails (this.notes[i], this.parameters[i]);
+      new_details.add (note);
+    }
+
+    return new_details;
+  }
+}
+
+public class Contacts.PostalAddressesField : PropertyField {
+
+  protected ArrayList<TypeDescriptor> types = new ArrayList<TypeDescriptor> ();
+  protected ArrayList<PostalAddress> addresses = new ArrayList<PostalAddress> ();
+  protected ArrayList<unowned MultiMap<string, string>?> parameters
+      = new ArrayList<unowned MultiMap<string, string>?> ();
+
+  public PostalAddressesField (Persona persona) {
+    Object (
+      property_name: "postal-addresses",
+      persona: persona
+    );
+
+    unowned PostalAddressDetails? details = persona as PostalAddressDetails;
+    foreach (var address in details.postal_addresses) {
+      var type_descr = TypeSet.general.lookup_descriptor_for_field_details (address);
+      this.types.add (type_descr);
+      this.addresses.add (address.value);
+      this.parameters.add (address.parameters);
+    }
+  }
+
+  public static bool should_show (Persona persona) {
+    unowned PostalAddressDetails? details = persona as PostalAddressDetails;
+    return (details != null && !details.postal_addresses.is_empty);
+  }
+
+  protected override void create_widgets (PropertyWidget widget) {
+    for (int i = 0; i < addresses.size; i++)
+      add_field (this.types[i], this.addresses[i], widget);
+  }
+
+  protected virtual void add_field (TypeDescriptor type, PostalAddress address, PropertyWidget widget) {
+    var type_label = row.create_type_label (type.display_name);
+    var value_label = row.create_value_label (format_address (address));
+    widget.add_row (type_label, value_label);
+  }
+
+  private static string format_address (PostalAddress addr) {
+    string[] lines = {};
+
+    if (addr.street != "")
+      lines += addr.street;
+    if (addr.extension != "")
+      lines += addr.extension;
+    if (addr.locality != "")
+      lines += addr.locality;
+    if (addr.region != "")
+      lines += addr.region;
+    if (addr.postal_code != "")
+      lines += addr.postal_code;
+    if (addr.po_box != "")
+      lines += addr.po_box;
+    if (addr.country != "")
+      lines += addr.country;
+    if (addr.address_format != "")
+      lines += addr.address_format;
+
+    return string.joinv ("\n", lines);
+  }
+}
+
+public class Contacts.EditablePostalAddressesField : PostalAddressesField, EditableProperty {
+
+  public const string[] POSTAL_ELEMENT_PROPS = { "street", "extension", "locality", "region", "postal_code", 
"po_box", "country"};
+  public static string[] POSTAL_ELEMENT_NAMES = { _("Street"), _("Extension"), _("City"), 
_("State/Province"), _("Zip/Postal Code"), _("PO box"), _("Country")};
+
+  public EditablePostalAddressesField (Persona persona) {
+    base (persona);
+  }
+
+  public EditablePostalAddressesField.empty () {
+    Object (
+      property_name: "postal-addresses",
+      persona: null
+    );
+
+    this.types.add (TypeSet.general.lookup_descriptor_in_store ("Home"));
+    this.addresses.add (new PostalAddress ("", "", "", "", "", "", "", "", ""));
+    this.parameters.add (null);
+  }
+
+  protected override void add_field (TypeDescriptor type, PostalAddress address, PropertyWidget widget) {
+    var type_combo = row.create_type_combo (TypeSet.general, type);
+    type_combo.changed.connect ((combo) => {
+        var row_nr = row.get_child_row (combo);
+        this.types[row_nr] = type_combo.active_descriptor;
+      });
+
+    var grid = new Grid ();
+    grid.orientation = Orientation.VERTICAL;
+    for (int i = 0; i < POSTAL_ELEMENT_PROPS.length; i++) {
+      unowned string address_part = POSTAL_ELEMENT_PROPS[i];
+      string part;
+      address.get (address_part, out part);
+
+      var part_entry = widget.create_value_entry (part);
+      part_entry.get_style_context ().add_class ("contacts-postal-entry");
+      part_entry.placeholder_text = POSTAL_ELEMENT_NAMES[i];
+      grid.add (part_entry);
+    }
+    grid.show_all ();
+    var delete_button = row.create_delete_button (_("Remove postal address"));
+
+    widget.add_row (type_combo, grid, delete_button);
+  }
+
+  public Value? create_value () {
+    if (this.addresses.is_empty)
+      return null;
+
+    var new_details = create_set ();
+    // Check if we only had empty addresses
+    if (new_details.is_empty)
+      return null;
+
+    var result = Value (new_details.get_type ());
+    result.set_object (new_details);
+    return result;
+  }
+
+  public async void save_changes () throws PropertyError {
+    assert (this.persona != null);
+
+    var new_addrs = create_set ();
+    yield ((PostalAddressDetails) this.persona).change_postal_addresses (new_addrs);
+  }
+
+  private HashSet<PostalAddressFieldDetails>? create_set () {
+    var new_details = new HashSet<PostalAddressFieldDetails> ();
+    for (int i = 0; i < this.addresses.size; i++) {
+      if (is_empty_postal_address (this.addresses[i]))
+        continue;
+
+      var parameters = this.types[i].add_type_to_parameters (this.parameters[i]);
+      var address = new PostalAddressFieldDetails (this.addresses[i], parameters);
+      new_details.add (address);
+    }
+
+    return new_details;
+  }
+
+  private bool is_empty_postal_address (PostalAddress addr) {
+    return addr.po_box == "" &&
+           addr.extension == "" &&
+           addr.street == "" &&
+           addr.locality == "" &&
+           addr.region == "" &&
+           addr.postal_code == "" &&
+           addr.country == "" &&
+           addr.address_format == "";
+  }
+}
diff --git a/src/contacts-contact-form.vala b/src/contacts-contact-form.vala
index f44a2fb..28dd679 100644
--- a/src/contacts-contact-form.vala
+++ b/src/contacts-contact-form.vala
@@ -49,42 +49,117 @@ public abstract class Contacts.ContactForm : Grid {
 
   [GtkChild]
   protected Grid container_grid;
-  protected int last_row = 0;
+
+  [GtkChild]
+  protected ListBox form_container;
+  protected GLib.ListStore fields = new GLib.ListStore (typeof (PropertyField));
+
+  protected SizeGroup labels_sizegroup = new SizeGroup (SizeGroupMode.HORIZONTAL);
+  protected SizeGroup values_sizegroup = new SizeGroup (SizeGroupMode.HORIZONTAL);
+  protected SizeGroup actions_sizegroup = new SizeGroup (SizeGroupMode.HORIZONTAL);
+
+  // Seperate treatment for the header widgets
+  protected Widget avatar_widget;
+  protected Widget name_widget;
 
   construct {
     this.container_grid.set_focus_vadjustment (this.main_sw.get_vadjustment ());
     this.main_sw.get_style_context ().add_class ("contacts-contact-form");
+
+    this.form_container.bind_model (this.fields, create_row);
+    this.form_container.set_header_func (create_persona_store_header);
+  }
+
+  private Gtk.Widget create_row (Object object) {
+    return ((PropertyField) object).create_row (labels_sizegroup, values_sizegroup, actions_sizegroup);
   }
 
-  protected string[] sort_persona_properties (string[] props) {
-    CompareDataFunc<string> compare_properties = (a, b) => {
-        foreach (var prop in SORTED_PROPERTIES) {
-          if (a == prop)
-            return (b == prop)? 0 : -1;
+  private void create_persona_store_header (ListBoxRow row, ListBoxRow? before) {
+    // Leave out the persona store header at the start
+    if (before == null) {
+      row.set_header (null);
+      return;
+    }
+
+    PropertyWidget current = (PropertyWidget) row;
+    PropertyWidget previous = (PropertyWidget) before;
+    if (current.field.persona == null || previous.field.persona == null)
+      return;
+    if (current.field.persona == previous.field.persona)
+      return;
+
+    var label = create_persona_store_label (current.field.persona);
+    row.set_header (label);
+  }
 
-          if (b == prop)
-            return 1;
-        }
+  private static int compare_fields (Object obj_a, Object obj_b) {
+    unowned PropertyField a = (PropertyField) obj_a;
+    unowned PropertyField b = (PropertyField) obj_b;
 
-        return 0;
-      };
+    // First compare personas
+    var persona_comparison = Utils.compare_personas_on_store (a.persona, b.persona);
+    if (persona_comparison != 0)
+      return persona_comparison;
 
-    var sorted_props = new ArrayList<string> ();
-    foreach (var s in props)
-      sorted_props.add (s);
+    // Then compare the properties by name (so that they are sorted as in SORTED_PROPERTIES
+    var property_name_comp = compare_property_names (a.property_name, b.property_name);
+    if (property_name_comp != 0)
+      return property_name_comp;
 
-    sorted_props.sort ((owned) compare_properties);
-    return sorted_props.to_array ();
+    // Next, check for the VCard PREF attribute
+    // XXX TODO
+    return 0;
+  }
+
+  private static int compare_property_names (string a, string b) {
+    foreach (unowned string prop in SORTED_PROPERTIES) {
+      if (a == prop)
+        return (b == prop)? 0 : -1;
+
+      if (b == prop)
+        return 1;
+    }
+
+    return 0;
   }
 
   protected Label create_persona_store_label (Persona p) {
     var store_name = new Label("");
     store_name.set_markup (Markup.printf_escaped ("<span font='16px bold'>%s</span>",
                            Contact.format_persona_store_name_for_contact (p)));
-    store_name.set_halign (Align.START);
+    store_name.halign = Align.START;
     store_name.xalign = 0.0f;
     store_name.margin_start = 6;
+    store_name.visible = true;
 
     return store_name;
   }
+
+  protected PropertyField? get_property_field_for_name (string name) {
+    for (uint i = 0; i < this.fields.get_n_items(); i++) {
+      var field = (PropertyField) this.fields.get_item (i);
+      if (field.property_name == name)
+        return field;
+    }
+
+    return null;
+  }
+
+  protected void add_field (PropertyField field) {
+    this.fields.insert_sorted (field, compare_fields);
+  }
+
+  protected void attach_avatar_widget (Widget avatar_widget) {
+    avatar_widget.vexpand = false;
+    avatar_widget.halign = Align.START;
+    container_grid.attach (avatar_widget, 0, 0);
+    this.labels_sizegroup.add_widget (avatar_widget);
+  }
+
+  protected void set_name_widget (Widget name_widget) {
+    this.name_widget = name_widget;
+    this.name_widget.hexpand = true;
+    this.name_widget.valign = Align.CENTER;
+    this.container_grid.attach (this.name_widget, 1, 0);
+  }
 }
diff --git a/src/contacts-contact-pane.vala b/src/contacts-contact-pane.vala
index 1fabeff..370cdd9 100644
--- a/src/contacts-contact-pane.vala
+++ b/src/contacts-contact-pane.vala
@@ -221,10 +221,19 @@ public class Contacts.ContactPane : Stack {
     if (!this.on_edit_mode)
       return;
 
+    // Get a strong reference
+    ContactEditor editor = this.editor;
     this.on_edit_mode = false;
     /* saving changes */
-    if (!drop_changes)
-      save_editor_changes.begin ();
+    if (!drop_changes) {
+      editor.save_changes.begin ((obj, res) => {
+          try {
+            editor.save_changes.end (res);
+          } catch (Error e) {
+            show_message (e.message);
+          }
+        });
+    }
 
     remove_contact_editor ();
 
@@ -234,35 +243,6 @@ public class Contacts.ContactPane : Stack {
       set_visible_child (this.none_selected_page);
   }
 
-  private async void save_editor_changes () {
-    foreach (var prop in this.editor.properties_changed ().entries) {
-      try {
-        yield Contact.set_persona_property (prop.value.persona, prop.key, prop.value.value);
-      } catch (Error e) {
-        show_message (e.message);
-      }
-    }
-
-    if (this.editor.name_changed ()) {
-      var v = this.editor.get_full_name_value ();
-      try {
-        yield this.contact.set_individual_property ("full-name", v);
-        display_name_changed (v.get_string ());
-      } catch (Error e) {
-        show_message (e.message);
-      }
-    }
-
-    if (this.editor.avatar_changed ()) {
-      var v = this.editor.get_avatar_value ();
-      try {
-        yield this.contact.set_individual_property ("avatar", v);
-      } catch (Error e) {
-        show_message (e.message);
-      }
-    }
-  }
-
   public void new_contact () {
     this.on_edit_mode = true;
     this.contact = null;
@@ -273,17 +253,7 @@ public class Contacts.ContactPane : Stack {
 
   // Creates a new contact from the details in the ContactEditor
   public async void create_contact () {
-    var details = new HashTable<string, Value?> (str_hash, str_equal);
-
-    // Collect the details from the editor
-    if (editor.name_changed ())
-      details["full-name"] = this.editor.get_full_name_value ();
-
-    if (editor.avatar_changed ())
-      details["avatar"] = this.editor.get_avatar_value ();
-
-    foreach (var prop in this.editor.properties_changed ().entries)
-      details[prop.key] = prop.value.value;
+    var details = this.editor.create_details_for_new_contact ();
 
     // Leave edit mode
     stop_editing (true);
diff --git a/src/contacts-contact-sheet.vala b/src/contacts-contact-sheet.vala
index 62f2e3f..7b11ea1 100644
--- a/src/contacts-contact-sheet.vala
+++ b/src/contacts-contact-sheet.vala
@@ -27,103 +27,51 @@ using Gee;
 public class Contacts.ContactSheet : ContactForm {
 
   public ContactSheet (Contact contact, Store store) {
-      this.contact = contact;
-      this.store = store;
+    this.contact = contact;
+    this.store = store;
 
-      this.contact.individual.notify.connect (update);
-      this.contact.individual.personas_changed.connect (update);
-      this.store.quiescent.connect (update);
+    this.contact.individual.notify.connect (update);
+    this.contact.individual.personas_changed.connect (update);
+    this.store.quiescent.connect (update);
 
-      update ();
-  }
-
-  private Button add_row_with_button (string label, string value, bool use_link_button = false) {
-    var type_label = new Label (label);
-    type_label.xalign = 1.0f;
-    type_label.set_halign (Align.END);
-    type_label.get_style_context ().add_class ("dim-label");
-    this.container_grid.attach (type_label, 0, this.last_row);
-
-    var value_button = use_link_button? new LinkButton (value) : new Button.with_label (value);
-    value_button.focus_on_click = false;
-    value_button.relief = ReliefStyle.NONE;
-    value_button.halign = Align.START;
-    this.container_grid.attach (value_button, 1, this.last_row);
-    this.last_row++;
-
-    (value_button.get_child () as Label).set_ellipsize (Pango.EllipsizeMode.END);
-    (value_button.get_child () as Label).wrap_mode = Pango.WrapMode.CHAR;
-
-    return value_button;
-  }
-
-  void add_row_with_label (string label_value, string value) {
-    var type_label = new Label (label_value);
-    type_label.xalign = 1.0f;
-    type_label.set_halign (Align.END);
-    type_label.set_valign (Align.START);
-    type_label.get_style_context ().add_class ("dim-label");
-    this.container_grid.attach (type_label, 0, this.last_row, 1, 1);
-
-    var value_label = new Label (value);
-    value_label.set_line_wrap (true);
-    value_label.xalign = 0.0f;
-    value_label.set_halign (Align.START);
-    value_label.set_ellipsize (Pango.EllipsizeMode.END);
-    value_label.wrap_mode = Pango.WrapMode.CHAR;
-    value_label.set_selectable (true);
-
-    /* FIXME: hardcode gap to match the button size */
-    type_label.margin_top = 3;
-    value_label.margin_start = 6;
-    value_label.margin_top = 3;
-    value_label.margin_bottom = 3;
-
-    this.container_grid.attach (value_label, 1, this.last_row, 1, 1);
-    this.last_row++;
+    update ();
   }
 
   private void update () {
-    this.last_row = 0;
-    this.container_grid.foreach ((child) => this.container_grid.remove (child));
+    clear_previous_details ();
 
-    var image_frame = new Avatar (PROFILE_SIZE, this.contact);
-    image_frame.set_vexpand (false);
-    image_frame.set_valign (Align.START);
-    this.container_grid.attach (image_frame,  0, 0, 1, 3);
+    this.avatar_widget = new Avatar (PROFILE_SIZE, this.contact);
+    attach_avatar_widget (this.avatar_widget);
 
     create_name_label ();
 
-    this.last_row += 3; // Name/Avatar takes up 3 rows
-
-    var personas = this.contact.get_personas_for_display ();
-    /* Cause personas are sorted properly I can do this */
-    foreach (var p in personas) {
-      bool is_first_persona = (this.last_row == 3);
-      int persona_store_pos = this.last_row;
-      if (!is_first_persona) {
-        this.container_grid.attach (create_persona_store_label (p), 0, this.last_row, 3);
-        this.last_row++;
-      }
-
-      foreach (var prop in ContactForm.SORTED_PROPERTIES)
-        add_row_for_property (p, prop);
-
-      // Nothing to show in the persona: don't mention it
-      bool is_empty_persona = (this.last_row == persona_store_pos + 1);
-      if (!is_first_persona && is_empty_persona) {
-        this.container_grid.remove_row (persona_store_pos);
-        this.last_row--;
+    foreach (var p in this.contact.individual.personas) {
+      foreach (var prop in ContactForm.SORTED_PROPERTIES) {
+        var field = add_row_for_property (p, prop);
+        if (field != null)
+          add_field (field);
       }
     }
 
     show_all ();
   }
 
-  private void update_name_label (Gtk.Label name_label) {
+  private void clear_previous_details () {
+    if (this.avatar_widget != null)
+      this.avatar_widget.destroy ();
+    this.avatar_widget = null;
+
+    if (this.name_widget != null)
+      this.name_widget.destroy ();
+    this.name_widget = null;
+
+    this.fields.remove_all ();
+  }
+
+  private void update_name_widget () {
     var name = Markup.printf_escaped ("<span font='16'>%s</span>",
                                       this.contact.individual.display_name);
-    name_label.set_markup (name);
+    ((Label) this.name_widget).set_markup (name);
   }
 
   private void create_name_label () {
@@ -131,139 +79,49 @@ public class Contacts.ContactSheet : ContactForm {
     name_label.ellipsize = Pango.EllipsizeMode.END;
     name_label.xalign = 0f;
     name_label.selectable = true;
-    this.container_grid.attach (name_label,  1, 0, 1, 3);
-    update_name_label (name_label);
+    set_name_widget (name_label);
+
+    update_name_widget ();
     this.contact.individual.notify["display-name"].connect ((obj, spec) => {
-        update_name_label (name_label);
+        update_name_widget ();
       });
   }
 
-  private void add_row_for_property (Persona persona, string property) {
-    switch (property) {
+  private PropertyField? add_row_for_property (Persona persona, string property_name) {
+    switch (property_name) {
       case "email-addresses":
-        add_emails (persona);
+        if (EmailsField.should_show (persona))
+          return new EmailsField (persona);
         break;
       case "phone-numbers":
-        add_phone_nrs (persona);
-        break;
-      case "im-addresses":
-        add_im_addresses (persona);
+        if (PhoneNrsField.should_show (persona))
+          return new PhoneNrsField (persona);
         break;
       case "urls":
-        add_urls (persona);
+        if (UrlsField.should_show (persona))
+          return new UrlsField (persona);
         break;
       case "nickname":
-        add_nickname (persona);
+        if (NicknameField.should_show (persona))
+          return new NicknameField (persona);
         break;
       case "birthday":
-        add_birthday (persona);
+        if (BirthdayField.should_show (persona))
+          return new BirthdayField (persona);
         break;
       case "notes":
-        add_notes (persona);
+        if (NotesField.should_show (persona))
+          return new NotesField (persona);
         break;
       case "postal-addresses":
-        add_postal_addresses (persona);
+        if (PostalAddressesField.should_show (persona))
+          return new PostalAddressesField (persona);
         break;
       default:
-        debug ("Unsupported property: %s", property);
+        debug ("Unsupported property: %s", property_name);
         break;
     }
-  }
 
-  private void add_emails (Persona persona) {
-    var details = persona as EmailDetails;
-    if (details != null) {
-      var emails = Contact.sort_fields<EmailFieldDetails>(details.email_addresses);
-      foreach (var email in emails) {
-          var button = add_row_with_button (TypeSet.email.format_type (email), email.value);
-          button.clicked.connect (() => {
-          Utils.compose_mail ("%s <%s>".printf(this.contact.individual.display_name, email.value));
-        });
-      }
-    }
-  }
-
-  private void add_phone_nrs (Persona persona) {
-    var phone_details = persona as PhoneDetails;
-    if (phone_details != null) {
-      var phones = Contact.sort_fields<PhoneFieldDetails>(phone_details.phone_numbers);
-      foreach (var phone in phones) {
-#if HAVE_TELEPATHY
-        if (this.store.caller_account != null) {
-          var button = add_row_with_button (TypeSet.phone.format_type (phone), phone.value);
-          button.clicked.connect (() => {
-              Utils.start_call (phone.value, this.store.caller_account);
-            });
-        } else {
-          add_row_with_label (TypeSet.phone.format_type (phone), phone.value);
-        }
-#else
-        add_row_with_label (TypeSet.phone.format_type (phone), phone.value);
-#endif
-      }
-    }
-  }
-
-  private void add_im_addresses (Persona persona) {
-#if HAVE_TELEPATHY
-    var im_details = persona as ImDetails;
-    if (im_details != null) {
-      foreach (var protocol in im_details.im_addresses.get_keys ()) {
-        foreach (var id in im_details.im_addresses[protocol]) {
-          if (persona is Tpf.Persona) {
-            var button = add_row_with_button (ImService.get_display_name (protocol), id.value);
-            button.clicked.connect (() => {
-                var im_persona = this.contact.find_im_persona (protocol, id.value);
-                if (im_persona != null) {
-                  var type = im_persona.presence_type;
-                  if (type != PresenceType.UNSET && type != PresenceType.ERROR &&
-                      type != PresenceType.OFFLINE && type != PresenceType.UNKNOWN) {
-                    Utils.start_chat (this.contact, protocol, id.value);
-                  }
-                }
-              });
-          }
-        }
-      }
-    }
-#endif
-  }
-
-  private void add_urls (Persona persona) {
-    var url_details = persona as UrlDetails;
-    if (url_details != null) {
-      foreach (var url in url_details.urls)
-        add_row_with_button (_("Website"), url.value, true);
-    }
-  }
-
-  private void add_nickname (Persona persona) {
-    var name_details = persona as NameDetails;
-    if (name_details != null && is_set (name_details.nickname))
-      add_row_with_label (_("Nickname"), name_details.nickname);
-  }
-
-  private void add_birthday (Persona persona) {
-    var birthday_details = persona as BirthdayDetails;
-    if (birthday_details != null && birthday_details.birthday != null)
-      add_row_with_label (_("Birthday"), birthday_details.birthday.to_local ().format ("%x"));
-  }
-
-  private void add_notes (Persona persona) {
-    var note_details = persona as NoteDetails;
-    if (note_details != null) {
-      foreach (var note in note_details.notes)
-        add_row_with_label (_("Note"), note.value);
-    }
-  }
-
-  private void add_postal_addresses (Persona persona) {
-    var addr_details = persona as PostalAddressDetails;
-    if (addr_details != null) {
-      foreach (var addr in addr_details.postal_addresses) {
-        var all_strs = string.joinv ("\n", Contact.format_address (addr.value));
-        add_row_with_label (TypeSet.general.format_type (addr), all_strs);
-      }
-    }
+    return null;
   }
 }
diff --git a/src/contacts-contact.vala b/src/contacts-contact.vala
index f0cc442..35056f5 100644
--- a/src/contacts-contact.vala
+++ b/src/contacts-contact.vala
@@ -82,7 +82,7 @@ public class Contacts.Contact : GLib.Object  {
       return false;
 
     // Mark google contacts not in "My Contacts" as non-main
-    return !persona_is_google_other (persona);
+    return !Utils.persona_is_google_other (persona);
   }
 
   private bool calc_is_main () {
@@ -134,89 +134,6 @@ public class Contacts.Contact : GLib.Object  {
     return false;
   }
 
-  private static bool has_pref (AbstractFieldDetails details) {
-    var evolution_pref = details.get_parameter_values ("x-evolution-ui-slot");
-    if (evolution_pref != null && Utils.get_first (evolution_pref) == "1")
-      return true;
-
-    foreach (var param in details.parameters["type"]) {
-      if (param.ascii_casecmp ("PREF") == 0)
-        return true;
-    }
-    return false;
-  }
-
-  private static TypeSet select_typeset_from_fielddetails (AbstractFieldDetails a) {
-    if (a is EmailFieldDetails)
-      return TypeSet.email;
-    if (a is PhoneFieldDetails)
-      return TypeSet.phone;
-    return TypeSet.general;
-  }
-
-  public static int compare_fields (void* _a, void* _b) {
-    var a = (AbstractFieldDetails) _a;
-    var b = (AbstractFieldDetails) _b;
-
-    // Fields with a PREF hint always go first (see VCard PREF attribute)
-    var a_has_pref = has_pref (a);
-    if (a_has_pref != has_pref (b))
-      return (a_has_pref)? -1 : 1;
-
-    // sort by field type first (e.g. "Home", "Work")
-    var type_set = select_typeset_from_fielddetails (a);
-    var result = type_set.format_type (a).ascii_casecmp (type_set.format_type (b));
-    if (result != 0)
-      return result;
-
-    // Try to compare by value if types are equal
-    var aa = a as AbstractFieldDetails<string>;
-    var bb = b as AbstractFieldDetails<string>;
-    if (aa != null && bb != null)
-      return strcmp (aa.value, bb.value);
-
-    // No heuristics to fall back to.
-    warning ("Unsupported AbstractFieldDetails value type");
-    return 0;
-  }
-
-  public static Gee.List<T> sort_fields<T> (Collection<T> fields) {
-    var res = new ArrayList<T>();
-    res.add_all (fields);
-    res.sort (Contact.compare_fields);
-    return res;
-  }
-
-  public static 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;
-  }
-
 #if HAVE_TELEPATHY
   public Tpf.Persona? find_im_persona (string protocol, string im_address) {
     var iid = protocol + ":" + im_address;
@@ -286,38 +203,12 @@ public class Contacts.Contact : GLib.Object  {
   }
 
   public Gee.List<Persona> get_personas_for_display () {
-    CompareDataFunc<Persona> compare_persona_by_store = (a, b) => {
-        var store_a = a.store;
-        var store_b = b.store;
-
-        // In the same store, sort Google 'other' contacts last
-        if (store_a == store_b) {
-          if (!persona_is_google (a))
-            return 0;
-
-          var a_is_other = persona_is_google_other (a);
-          if (a_is_other != persona_is_google_other (b))
-            return a_is_other? 1 : -1;
-        }
-
-        // Sort primary stores before others
-        if (store_a.is_primary_store != store_b.is_primary_store)
-          return (store_a.is_primary_store)? -1 : 1;
-
-        // E-D-S stores get prioritized
-        if ((store_a.type_id == "eds") != (store_b.type_id == "eds"))
-          return (store_a.type_id == "eds")? -1 : 1;
-
-        // Normal case: use alphabetical sorting
-        return strcmp (store_a.id, store_b.id);
-      };
-
     var persona_list = new ArrayList<Persona>();
     foreach (var persona in individual.personas)
       if (persona.store.type_id != "key-file")
         persona_list.add (persona);
 
-    persona_list.sort ((owned) compare_persona_by_store);
+    persona_list.sort (Utils.compare_personas_on_store);
     return persona_list;
   }
 
@@ -373,7 +264,7 @@ public class Contacts.Contact : GLib.Object  {
   public bool has_mainable_persona () {
     foreach (var p in individual.personas) {
       if (p.store.type_id == "eds" &&
-         !persona_is_google_other (p))
+         !Utils.persona_is_google_other (p))
        return true;
     }
     return false;
@@ -385,8 +276,8 @@ public class Contacts.Contact : GLib.Object  {
     bool all_unlinkable = true;
 
     foreach (var p in individual.personas) {
-      if (!persona_is_google_other (p) ||
-         persona_is_google_profile (p))
+      if (!Utils.persona_is_google_other (p) ||
+         Utils.persona_is_google_profile (p))
        all_unlinkable = false;
     }
 
@@ -405,42 +296,12 @@ public class Contacts.Contact : GLib.Object  {
     return !this.is_main || !other.has_mainable_persona();
   }
 
-  private static bool persona_is_google (Persona persona) {
-    return persona.store.type_id == "eds" && esource_uid_is_google (persona.store.id);
-  }
-
-  /**
-   * Return true only for personas which are in a Google address book, but which
-   * are not in the user's "My Contacts" group in the address book.
-   */
-  public static bool persona_is_google_other (Persona persona) {
-    if (!persona_is_google (persona))
-      return false;
-
-    var p = persona as Edsf.Persona;
-    return p != null && !p.in_google_personal_group;
-  }
-
-  public static bool persona_is_google_profile (Persona persona) {
-    if (!persona_is_google_other (persona))
-      return false;
-
-    var u = persona as UrlDetails;
-    if (u != null && u.urls.size == 1) {
-      foreach (var url in u.urls) {
-       if (/https?:\/\/www.google.com\/profiles\/[0-9]+$/.match(url.value))
-         return true;
-      }
-    }
-    return false;
-  }
-
   public static string format_persona_store_name_for_contact (Persona persona) {
     var store = persona.store;
     if (store.type_id == "eds") {
-      if (persona_is_google_profile (persona))
+      if (Utils.persona_is_google_profile (persona))
        return _("Google Circles");
-      else if (persona_is_google_other (persona))
+      else if (Utils.persona_is_google_other (persona))
        return _("Google");
 
       string? eds_name = lookup_esource_name_by_uid_for_contact (store.id);
diff --git a/src/contacts-type-descriptor.vala b/src/contacts-type-descriptor.vala
index efc96ce..5e1cfcc 100644
--- a/src/contacts-type-descriptor.vala
+++ b/src/contacts-type-descriptor.vala
@@ -89,26 +89,36 @@ public class Contacts.TypeDescriptor : Object {
     return this.source == Source.CUSTOM;
   }
 
-  public void save_to_field_details (AbstractFieldDetails details) {
+  /**
+   * Saves the type decribed by this object to the given parameters (as found
+   * in the parameters property of a {@link Folks.AbstractFieldDetails} object.
+   *
+   * If old_parameters is specified, it will also copy over all fields (that
+   * not related to the type of the property).
+   *
+   * @param old_parameters: The previous parameters to base on, or null if none.
+   */
+  public MultiMap<string, string> add_type_to_parameters (MultiMap<string, string>? old_parameters) {
     debug ("Saving type %s", to_string ());
 
-    var old_parameters = details.parameters;
     var new_parameters = new HashMultiMap<string, string> ();
 
     // Check whether PREF VCard "flag" is set
     bool has_pref = false;
-    foreach (var val in old_parameters["type"]) {
-      if (val.ascii_casecmp ("PREF") == 0) {
-        has_pref = true;
-        break;
+    if (old_parameters != null) {
+      foreach (var val in old_parameters["type"]) {
+        if (val.ascii_casecmp ("PREF") == 0) {
+          has_pref = true;
+          break;
+        }
       }
-    }
 
-    // Copy over all parameters, execept the ones we're going to create ourselves
-    foreach (var param in old_parameters.get_keys ()) {
-      if (param != "type" && param != X_GOOGLE_LABEL)
-        foreach (var val in old_parameters[param])
-          new_parameters[param] = val;
+      // Copy over all parameters, execept the ones we're going to create ourselves
+      foreach (var param in old_parameters.get_keys ()) {
+        if (param != "type" && param != X_GOOGLE_LABEL)
+          foreach (var val in old_parameters[param])
+            new_parameters[param] = val;
+      }
     }
 
     // Set the type based on our Source
@@ -130,8 +140,7 @@ public class Contacts.TypeDescriptor : Object {
     if (has_pref)
       new_parameters["type"] = "PREF";
 
-    // We didn't crash 'n burn, so lets
-    details.parameters = new_parameters;
+    return new_parameters;
   }
 
   /**
diff --git a/src/contacts-utils.vala b/src/contacts-utils.vala
index 5552e8c..e82e8ba 100644
--- a/src/contacts-utils.vala
+++ b/src/contacts-utils.vala
@@ -70,15 +70,6 @@ namespace Contacts {
 }
 
 namespace Contacts.Utils {
-  public void compose_mail (string email) {
-    var mailto_uri = "mailto:"; + Uri.escape_string (email, "@" , false);
-    try {
-      Gtk.show_uri_on_window (null, mailto_uri, 0);
-    } catch (Error e) {
-      debug ("Couldn't launch URI \"%s\": %s", mailto_uri, e.message);
-    }
-  }
-
 #if HAVE_TELEPATHY
   public void start_chat (Contact contact, string protocol, string id) {
     var im_persona = contact.find_im_persona (protocol, id);
@@ -173,4 +164,63 @@ namespace Contacts.Utils {
     dialog.run();
     dialog.destroy();
   }
+
+  public static int compare_personas_on_store (Persona? a, Persona? b) {
+    if (a == null || b == null)
+      return (a == null)? -1 : 1;
+
+    var store_a = a.store;
+    var store_b = b.store;
+
+    // In the same store, sort Google 'other' contacts last
+    if (store_a == store_b) {
+      if (!Utils.persona_is_google (a))
+        return 0;
+
+      var a_is_other = Utils.persona_is_google_other (a);
+      if (a_is_other != Utils.persona_is_google_other (b))
+        return a_is_other? 1 : -1;
+    }
+
+    // Sort primary stores before others
+    if (store_a.is_primary_store != store_b.is_primary_store)
+      return (store_a.is_primary_store)? -1 : 1;
+
+    // E-D-S stores get prioritized
+    if ((store_a.type_id == "eds") != (store_b.type_id == "eds"))
+      return (store_a.type_id == "eds")? -1 : 1;
+
+    // Normal case: use alphabetical sorting
+    return strcmp (store_a.id, store_b.id);
+  }
+
+  public bool persona_is_google (Persona persona) {
+    return persona.store.type_id == "eds" && esource_uid_is_google (persona.store.id);
+  }
+
+  /**
+   * Return true only for personas which are in a Google address book, but which
+   * are not in the user's "My Contacts" group in the address book.
+   */
+  public bool persona_is_google_other (Persona persona) {
+    if (!persona_is_google (persona))
+      return false;
+
+    var p = persona as Edsf.Persona;
+    return p != null && !p.in_google_personal_group;
+  }
+
+  public bool persona_is_google_profile (Persona persona) {
+    if (!persona_is_google_other (persona))
+      return false;
+
+    var u = persona as UrlDetails;
+    if (u != null && u.urls.size == 1) {
+      foreach (var url in u.urls) {
+        if (/https?:\/\/www.google.com\/profiles\/[0-9]+$/.match(url.value))
+        return true;
+      }
+    }
+    return false;
+  }
 }
diff --git a/src/meson.build b/src/meson.build
index 773c3dc..5231505 100644
--- a/src/meson.build
+++ b/src/meson.build
@@ -80,6 +80,7 @@ contacts_vala_sources = files(
   'contacts-avatar-selector.vala',
   'contacts-contact-editor.vala',
   'contacts-contact-form.vala',
+  'contacts-contact-form-field.vala',
   'contacts-contact-list.vala',
   'contacts-contact-pane.vala',
   'contacts-contact-sheet.vala',


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