[gnome-contacts/wip/nielsdg/contact-editor-rewrite: 4/4] ContactEditor: Rewrite



commit 7f1dd656af97658ecc7565fc704a3080b9dc045f
Author: Niels De Graef <nielsdegraef gmail com>
Date:   Wed Sep 20 02:11:59 2017 +0200

    ContactEditor: Rewrite
    
    TODO:
    
    * Check whether changes are always properly saved
    * Check if saved when no name was filled in
    * Add properties

 data/ui/contacts-contact-editor.ui                 |   43 +-
 src/contacts-avatar-selector.vala                  |    4 +-
 src/contacts-contact-editor.vala                   | 1013 --------------------
 src/contacts-contact-pane.vala                     |  221 ++---
 src/contacts-types.vala                            |   42 +-
 src/editor/contacts-editor-addresses-editor.vala   |  114 +++
 src/editor/contacts-editor-avatar-editor.vala      |   86 ++
 src/editor/contacts-editor-birthday-editor.vala    |  145 +++
 src/editor/contacts-editor-composite-editor.vala   |   66 ++
 src/editor/contacts-editor-contact-editor.vala     |  259 +++++
 .../contacts-editor-details-editor-factory.vala    |  101 ++
 src/editor/contacts-editor-details-editor.vala     |  136 +++
 src/editor/contacts-editor-emails-editor.vala      |   74 ++
 src/editor/contacts-editor-full-name-editor.vala   |   50 +
 src/editor/contacts-editor-nickname-editor.vala    |   55 ++
 src/editor/contacts-editor-notes-editor.vala       |   75 ++
 src/editor/contacts-editor-phones-editor.vala      |   76 ++
 src/editor/contacts-editor-urls-editor.vala        |   68 ++
 src/meson.build                                    |   15 +-
 19 files changed, 1413 insertions(+), 1230 deletions(-)
---
diff --git a/data/ui/contacts-contact-editor.ui b/data/ui/contacts-contact-editor.ui
index 57a862f..81886f7 100644
--- a/data/ui/contacts-contact-editor.ui
+++ b/data/ui/contacts-contact-editor.ui
@@ -4,52 +4,36 @@
 
   <menu id="edit-contact-menu">
     <item>
-      <attribute name="action">edit.add.email-addresses.home</attribute>
-      <attribute name="label" translatable="yes">Home email</attribute>
+      <attribute name="action">edit.add-email-addresses</attribute>
+      <attribute name="label" translatable="yes">Email address</attribute>
     </item>
     <item>
-      <attribute name="action">edit.add.email-addresses.work</attribute>
-      <attribute name="label" translatable="yes">Work email</attribute>
+      <attribute name="action">edit.add-phone-numbers</attribute>
+      <attribute name="label" translatable="yes">Phone number</attribute>
     </item>
     <item>
-      <attribute name="action">edit.add.phone-numbers.cell</attribute>
-      <attribute name="label" translatable="yes">Mobile phone</attribute>
-    </item>
-    <item>
-      <attribute name="action">edit.add.phone-numbers.home</attribute>
-      <attribute name="label" translatable="yes">Home phone</attribute>
-    </item>
-    <item>
-      <attribute name="action">edit.add.phone-numbers.work</attribute>
-      <attribute name="label" translatable="yes">Work phone</attribute>
-    </item>
-    <item>
-      <attribute name="action">edit.add.urls</attribute>
+      <attribute name="action">edit.add-urls</attribute>
       <attribute name="label" translatable="yes">Website</attribute>
     </item>
     <item>
-      <attribute name="action">edit.add.nickname</attribute>
+      <attribute name="action">edit.add-nickname</attribute>
       <attribute name="label" translatable="yes">Nickname</attribute>
     </item>
     <item>
-      <attribute name="action">edit.add.birthday</attribute>
+      <attribute name="action">edit.add-birthday</attribute>
       <attribute name="label" translatable="yes">Birthday</attribute>
     </item>
     <item>
-      <attribute name="action">edit.add.postal-addresses.home</attribute>
-      <attribute name="label" translatable="yes">Home address</attribute>
-    </item>
-    <item>
-      <attribute name="action">edit.add.postal-addresses.work</attribute>
-      <attribute name="label" translatable="yes">Work address</attribute>
+      <attribute name="action">edit.add-postal-addresses</attribute>
+      <attribute name="label" translatable="yes">Address</attribute>
     </item>
     <item>
-      <attribute name="action">edit.add.notes</attribute>
+      <attribute name="action">edit.add-notes</attribute>
       <attribute name="label" translatable="yes">Notes</attribute>
     </item>
   </menu>
 
-  <template class="ContactsContactEditor" parent="GtkGrid">
+  <template class="ContactsEditorContactEditor" parent="GtkGrid">
     <property name="visible">True</property>
     <property name="orientation">vertical</property>
     <child>
@@ -69,7 +53,6 @@
             <property name="column_spacing">12</property>
             <property name="hexpand">True</property>
             <property name="vexpand">True</property>
-            <signal name="size-allocate" handler="on_container_grid_size_allocate" after="true" />
           </object>
         </child>
       </object>
@@ -108,13 +91,13 @@
         </child>
         <child>
           <object class="GtkButton" id="linked_button">
-            <property name="visible">True</property>
+            <property name="visible">False</property>
             <property name="label" translatable="yes">Linked Accounts</property>
           </object>
         </child>
         <child>
           <object class="GtkButton" id="remove_button">
-            <property name="visible">True</property>
+            <property name="visible">False</property>
             <property name="label" translatable="yes">Remove Contact</property>
             <style>
               <class name="destructive-action"/>
diff --git a/src/contacts-avatar-selector.vala b/src/contacts-avatar-selector.vala
index 83e3631..f53287c 100644
--- a/src/contacts-avatar-selector.vala
+++ b/src/contacts-avatar-selector.vala
@@ -72,9 +72,9 @@ public class Contacts.AvatarSelector : Dialog {
    */
   public signal void set_avatar (GLib.Icon avatar_icon);
 
-  public AvatarSelector (Window main_window, Contact? contact) {
+  public AvatarSelector (Gtk.Window? parent, Contact? contact) {
     Object (
-      transient_for: main_window,
+      transient_for: parent,
       use_header_bar: 1
     );
 
diff --git a/src/contacts-contact-pane.vala b/src/contacts-contact-pane.vala
index 3f6a443..38d9924 100644
--- a/src/contacts-contact-pane.vala
+++ b/src/contacts-contact-pane.vala
@@ -44,30 +44,29 @@ public class Contacts.ContactPane : Stack {
 
   [GtkChild]
   private Box contact_editor_page;
-  private ContactEditor editor;
-
-  private SimpleActionGroup edit_contact_actions;
-  private const GLib.ActionEntry[] action_entries = {
-    { "add.email-addresses.home", on_add_detail },
-    { "add.email-addresses.work", on_add_detail },
-    { "add.phone-numbers.cell", on_add_detail },
-    { "add.phone-numbers.home", on_add_detail },
-    { "add.phone-numbers.work", on_add_detail },
-    { "add.urls", on_add_detail },
-    { "add.nickname", on_add_detail },
-    { "add.birthday", on_add_detail },
-    { "add.postal-addresses.home", on_add_detail },
-    { "add.postal-addresses.work", on_add_detail },
-    { "add.notes", on_add_detail },
-  };
+  private Editor.ContactEditor? editor;
 
   public bool on_edit_mode;
   private LinkSuggestionGrid suggestion_grid;
 
-  /* Signals */
+
   public signal void contacts_linked (string? main_contact, string linked_contact, LinkOperation operation);
   public signal void will_delete (Contact contact);
 
+
+  public ContactPane (Window parent_window, Store contacts_store) {
+    this.parent_window = parent_window;
+    this.store = contacts_store;
+    this.store.quiescent.connect (update_sheet);
+
+    create_contact_sheet ();
+
+    this.suggestion_grid = null;
+
+    /* edit mode widgetry, third page */
+    this.on_edit_mode = false;
+  }
+
   public void update_sheet () {
     if (on_edit_mode) {
       /* this was triggered by some signal, do nothing */
@@ -86,7 +85,7 @@ public class Contacts.ContactPane : Stack {
     foreach (var ind in matches.keys) {
       var c = Contact.from_individual (ind);
       if (c != null && contact.suggest_link_to (c)) {
-       add_suggestion (c);
+        add_suggestion (c);
       }
     }
   }
@@ -145,47 +144,6 @@ public class Contacts.ContactPane : Stack {
       set_visible_child (this.none_selected_page);
   }
 
-  public ContactPane (Window parent_window, Store contacts_store) {
-    this.parent_window = parent_window;
-    this.store = contacts_store;
-       this.store.quiescent.connect (update_sheet);
-
-    this.edit_contact_actions = new SimpleActionGroup ();
-    this.edit_contact_actions.add_action_entries (action_entries, this);
-
-    create_contact_sheet ();
-
-    this.suggestion_grid = null;
-
-    /* edit mode widgetry, third page */
-    this.on_edit_mode = false;
-    this.editor = new ContactEditor (this.edit_contact_actions);
-    this.editor.linked_button.clicked.connect (linked_accounts);
-    this.editor.remove_button.clicked.connect (delete_contact);
-    this.contact_editor_page.add (this.editor);
-
-    /* enable/disable actions*/
-    var birthday_action = this.edit_contact_actions.lookup_action ("add.birthday") as SimpleAction;
-    this.editor.bind_property ("has-birthday-row",
-                               birthday_action, "enabled",
-                               BindingFlags.SYNC_CREATE |
-                               BindingFlags.INVERT_BOOLEAN);
-
-    var nickname_action = this.edit_contact_actions.lookup_action ("add.nickname") as SimpleAction;
-    this.editor.bind_property ("has-nickname-row",
-                               nickname_action, "enabled",
-                               BindingFlags.DEFAULT |
-                               BindingFlags.SYNC_CREATE |
-                               BindingFlags.INVERT_BOOLEAN);
-
-    var notes_action = this.edit_contact_actions.lookup_action ("add.notes") as SimpleAction;
-    this.editor.bind_property ("has-notes-row",
-                               notes_action, "enabled",
-                               BindingFlags.DEFAULT |
-                               BindingFlags.SYNC_CREATE |
-                               BindingFlags.INVERT_BOOLEAN);
-  }
-
   private void create_contact_sheet () {
     this.sheet = new ContactSheet ();
     this.sheet.hexpand = true;
@@ -205,16 +163,6 @@ public class Contacts.ContactPane : Stack {
     this.contact_sheet_page.get_child ().get_style_context ().add_class ("view");
   }
 
-  void on_add_detail (GLib.SimpleAction action, GLib.Variant? parameter) {
-    var tok = action.name.split (".");
-
-    if (tok[0] == "add") {
-      editor.add_new_row_for_property (contact.find_primary_persona (),
-                                      tok[1],
-                                      tok.length > 2 ? tok[2].up () : null);
-    }
-  }
-
   private void linked_accounts () {
     var dialog = new LinkedPersonasDialog (this.parent_window, contact);
     if (dialog.run () == ResponseType.CLOSE && dialog.any_unlinked) {
@@ -225,6 +173,22 @@ public class Contacts.ContactPane : Stack {
     dialog.destroy ();
   }
 
+  // Start editing a contact: initialize and show the contact editor
+  private void load_contact_editor (Contact? contact) {
+    this.editor = new Editor.ContactEditor (contact, this.store);
+    this.editor.linked_button.clicked.connect (linked_accounts);
+    this.editor.remove_button.clicked.connect (delete_contact);
+    this.contact_editor_page.add (this.editor);
+    set_visible_child (this.contact_editor_page);
+  }
+
+  private void remove_contact_editor () {
+    SignalHandler.disconnect_by_func (this.editor.linked_button, (void*) linked_accounts, this);
+    SignalHandler.disconnect_by_func (this.editor.remove_button, (void*) delete_contact, this);
+    this.contact_editor_page.remove (this.editor);
+    this.editor = null;
+  }
+
   void delete_contact () {
     if (contact != null) {
       contact.hide ();
@@ -238,72 +202,38 @@ public class Contacts.ContactPane : Stack {
       return;
 
     if (on_edit) {
-      if (contact == null) {
-       return;
-      }
+      if (this.contact == null)
+        return;
 
-      on_edit_mode = true;
+      this.on_edit_mode = true;
 
-      sheet.clear ();
+      this.sheet.clear ();
 
       if (suggestion_grid != null) {
-       suggestion_grid.destroy ();
-       suggestion_grid = null;
+        this.suggestion_grid.destroy ();
+        this.suggestion_grid = null;
       }
 
-      editor.clear ();
-      editor.edit (contact);
-      editor.show_all ();
-      set_visible_child (this.contact_editor_page);
+      load_contact_editor (this.contact);
     } else {
-      on_edit_mode = false;
+      this.on_edit_mode = false;
       /* saving changes */
       if (!drop_changes) {
-       foreach (var prop in editor.properties_changed ().entries) {
-         Contact.set_persona_property.begin (prop.value.persona, prop.key, prop.value.value,
-                                             (obj, result) => {
-                                               try {
-                                                 Contact.set_persona_property.end (result);
-                                               } catch (Error e2) {
-                                                 show_message (e2.message);
-                                                 update_sheet ();
-                                               }
-                                             });
-       }
-
-       if (editor.name_changed ()) {
-         var v = editor.get_full_name_value ();
-         Contact.set_individual_property.begin (contact,
-                                                "full-name", v,
-                                                (obj, result) => {
-                                                  try {
-                                                    Contact.set_individual_property.end (result);
-                                                  } catch (Error e) {
-                                                    show_message (e.message);
-                                                    /* FIXME: add this back */
-                                                    /* l.set_markup (Markup.printf_escaped ("<span 
font='16'>%s</span>", contact.display_name)); */
-                                                  }
-                                                });
-       }
-       if (editor.avatar_changed ()) {
-         var v = editor.get_avatar_value ();
-         Contact.set_individual_property.begin (contact,
-                                                "avatar", v,
-                                                (obj, result) => {
-                                                  try {
-                                                    Contact.set_individual_property.end (result);
-                                                  } catch (GLib.Error e) {
-                                                    show_message (e.message);
-                                                  }
-                                                });
-       }
+        this.editor.save_changes.begin ( (obj, res) => {
+            try {
+              this.editor.save_changes.end (res);
+            } catch (Error e) {
+              show_message (e.message);
+              update_sheet ();
+            }
+          });
       }
 
-      editor.clear ();
+      remove_contact_editor ();
 
-      if (contact != null) {
-        sheet.clear ();
-        sheet.update (contact);
+      if (this.contact != null) {
+        this.sheet.clear ();
+        this.sheet.update (contact);
         set_visible_child (this.contact_sheet_page);
       } else {
         set_visible_child (this.none_selected_page);
@@ -321,54 +251,25 @@ public class Contacts.ContactPane : Stack {
       suggestion_grid = null;
     }
 
-    editor.set_new_contact ();
-
-    set_visible_child (this.contact_editor_page);
+    this.contact = null;
+    load_contact_editor (this.contact);
   }
 
   // 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;
-
     // Leave edit mode
     set_edit_mode (false, true);
 
-    if (details.size () == 0) {
-      show_message_dialog (_("You need to enter some data"));
-      return;
-    }
-
-    if (this.store.aggregator.primary_store == null) {
-      show_message_dialog (_("No primary addressbook configured"));
-      return;
-    }
-
-    // Create the contact
-    var primary_store = this.store.aggregator.primary_store;
-    Persona? persona = null;
     try {
-      persona = yield Contact.create_primary_persona_for_details (primary_store, details);
+      var contact = yield this.editor.save_changes ();
+      // Now show it to the user
+      if (contact != null)
+        this.parent_window.set_shown_contact (contact);
+      else
+        show_message_dialog (_("Unable to find newly created contact"));
     } catch (Error e) {
       show_message_dialog (_("Unable to create new contacts: %s").printf (e.message));
-      return;
     }
-
-    // Now show it to the user
-    var contact = this.store.find_contact_with_persona (persona);
-    if (contact != null)
-      this.parent_window.set_shown_contact (contact);
-    else
-      show_message_dialog (_("Unable to find newly created contact"));
   }
 
   private void show_message_dialog (string message) {
diff --git a/src/contacts-types.vala b/src/contacts-types.vala
index de8344d..0d2ce8f 100644
--- a/src/contacts-types.vala
+++ b/src/contacts-types.vala
@@ -227,24 +227,18 @@ public class Contacts.TypeSet : Object  {
     return _("Other");
   }
 
-  public void update_details (AbstractFieldDetails details, TreeIter iter) {
-    var old_parameters = details.parameters;
-    details.parameters = new HashMultiMap<string, string> ();
+  public void update_type_parameter (MultiMap<string, string> parameters, TreeIter iter) {
     bool has_pref = false;
-    foreach (var value in old_parameters.get ("type")) {
-      if (value.ascii_casecmp ("PREF") == 0) {
-       has_pref = true;
-       break;
-      }
-    }
-    foreach (var param in old_parameters.get_keys()) {
-      if (param != "type" && param != X_GOOGLE_LABEL) {
-       foreach (var value in old_parameters.get (param)) {
-         details.parameters.set (param, value);
-       }
+    foreach (var val in parameters["type"]) {
+      if (val.ascii_casecmp ("PREF") == 0) {
+        has_pref = true;
+        break;
       }
     }
 
+    parameters.remove_all("type");
+    parameters.remove_all(X_GOOGLE_LABEL);
+
     Data data;
     string display_name;
     store.get (iter, 0, out display_name, 1, out data);
@@ -253,21 +247,21 @@ public class Contacts.TypeSet : Object  {
     assert (data != custom_dummy); // Not custom...
 
     if (data == null) { // A custom label
-      details.parameters.set ("type", "OTHER");
-      details.parameters.set (X_GOOGLE_LABEL, display_name);
+      parameters["type"] = "OTHER";
+      parameters[X_GOOGLE_LABEL] = display_name;
     } else {
       if (data == other_dummy) {
-         details.parameters.set ("type", "OTHER");
+        parameters["type"] = "OTHER";
       } else {
-       InitData *init_data = data.init_data.data;
-       for (int j = 0; j < MAX_TYPES && init_data.types[j] != null; j++) {
-         details.parameters.set ("type", init_data.types[j]);
-       }
+        InitData *init_data = data.init_data.data;
+        for (int j = 0; j < MAX_TYPES && init_data.types[j] != null; j++) {
+          parameters["type"] = init_data.types[j];
+        }
       }
     }
 
     if (has_pref)
-      details.parameters.set ("type", "PREF");
+      parameters["type"] = "PREF";
   }
 
   public bool is_custom (TreeIter iter) {
@@ -493,9 +487,9 @@ public class Contacts.TypeCombo : Grid  {
     set_from_iter (iter);
   }
 
-  public void update_details (AbstractFieldDetails details) {
+  public void update_type_parameter (MultiMap<string, string> parameters) {
     TreeIter iter;
     combo.get_active_iter (out iter);
-    type_set.update_details (details, iter);
+    type_set.update_type_parameter (parameters, iter);
   }
 }
diff --git a/src/editor/contacts-editor-addresses-editor.vala 
b/src/editor/contacts-editor-addresses-editor.vala
new file mode 100644
index 0000000..ebda5f2
--- /dev/null
+++ b/src/editor/contacts-editor-addresses-editor.vala
@@ -0,0 +1,114 @@
+/*
+ * Copyright (C) 2017 Niels De Graef <nielsdegraef gmail com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+using Folks;
+using Gee;
+using Gtk;
+
+public class Contacts.Editor.AddressesEditor : CompositeEditor<PostalAddressDetails, 
PostalAddressFieldDetails> {
+
+  public override string persona_property {
+    get { return "postal-addresses"; }
+  }
+
+  public AddressesEditor (PostalAddressDetails? details = null) {
+    if (details != null) {
+      var address_fields = Contact.sort_fields<PostalAddressFieldDetails>(details.postal_addresses);
+      foreach (var address_field_detail in address_fields)
+        this.child_editors.add (new AddressEditor (this, address_field_detail));
+    } else {
+      // No addresss were passed on => make a blank home address
+      this.child_editors.add (new AddressEditor (this, null, "HOME"));
+    }
+  }
+
+  public override async void save (PostalAddressDetails address_details) throws PropertyError {
+    yield address_details.change_postal_addresses (aggregate_children ());
+  }
+
+  public class AddressEditor : CompositeEditorChild<PostalAddressFieldDetails> {
+    private TypeCombo type_combo;
+    private Box address_widget;
+    private Button delete_button;
+
+    public Entry? entries[7];  /* must be the number of elements in postal_element_props */
+    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 AddressEditor (AddressesEditor parent, PostalAddressFieldDetails? details = null, string? type = 
null) {
+      this.type_combo = parent.create_type_combo (TypeSet.general, details);
+      this.type_combo.valign = Gtk.Align.START;
+      this.address_widget = create_address_widget (parent);
+      this.delete_button = parent.create_delete_button ();
+      this.delete_button.valign = Gtk.Align.START;
+
+      if (details != null && details.value != null) {
+          var address = details.value;
+          this.entries[0].text = address.street ?? "";
+          this.entries[1].text = address.extension ?? "";
+          this.entries[2].text = address.locality ?? "";
+          this.entries[3].text = address.region ?? "";
+          this.entries[4].text = address.postal_code ?? "";
+          this.entries[5].text = address.po_box ?? "";
+          this.entries[6].text = address.country ?? "";
+      }
+      if (type != null)
+        this.type_combo.set_to (type);
+    }
+
+    public override int attach_to_grid (Grid container_grid, int row) {
+      container_grid.attach (this.type_combo, 0, row);
+      container_grid.attach (this.address_widget, 1, row);
+      container_grid.attach (this.delete_button, 2, row);
+
+      return 1;
+    }
+
+    public override PostalAddressFieldDetails create_details () {
+      var address = new PostalAddress (
+          this.entries[5].text, // po_box
+          this.entries[1].text, // extension
+          this.entries[0].text, // street
+          this.entries[2].text, // locality
+          this.entries[3].text, // region
+          this.entries[4].text, // postal_code
+          this.entries[6].text, // country
+          "derp?", // XXX
+          "");
+      // XXX parameters
+      return new PostalAddressFieldDetails (address, null);
+    }
+
+    private Box create_address_widget (AddressesEditor parent) {
+      var address_box = new Box(Orientation.VERTICAL, 0);
+      address_box.hexpand = true;
+      address_box.show ();
+
+      for (int i = 0; i < entries.length; i++) {
+        string? postal_part = null;
+        /* details.value.get (POSTAL_ELEMENT_PROPS[i], out postal_part); */
+
+        entries[i] = parent.create_entry (postal_part, POSTAL_ELEMENT_NAMES[i]);
+        entries[i].get_style_context ().add_class ("contacts-entry");
+        entries[i].get_style_context ().add_class ("contacts-postal-entry");
+        address_box.add (entries[i]);
+      }
+
+      return address_box;
+    }
+  }
+}
diff --git a/src/editor/contacts-editor-avatar-editor.vala b/src/editor/contacts-editor-avatar-editor.vala
new file mode 100644
index 0000000..ecb1e48
--- /dev/null
+++ b/src/editor/contacts-editor-avatar-editor.vala
@@ -0,0 +1,86 @@
+/*
+ * Copyright (C) 2017 Niels De Graef <nielsdegraef gmail com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+using Folks;
+using Gee;
+using Gtk;
+
+public class Contacts.Editor.AvatarEditor : DetailsEditor<AvatarDetails> {
+
+  private Contact? contact;
+
+  private Avatar avatar;
+
+  // The button containing the Avatar
+  private Button avatar_button;
+
+  private LoadableIcon? avatar_icon = null;
+
+  public override string persona_property {
+    get { return "avatar"; }
+  }
+
+  public AvatarEditor (Contact? contact = null, AvatarDetails? details = null) {
+    this.contact = contact;
+
+    //X XXX button
+    this.avatar = new Avatar (PROFILE_SIZE, contact);
+    this.avatar.vexpand = false;
+
+    this.avatar_button = new Button ();
+    this.avatar_button.image = this.avatar;
+    this.avatar_button.show ();
+    this.avatar_button.clicked.connect (on_avatar_button_clicked);
+  }
+
+  public override int attach_to_grid (Grid container_grid, int row) {
+    container_grid.attach (this.avatar_button, 0, row, 1, 3);
+    return 0;
+  }
+
+  public override async void save (AvatarDetails avatar_details) throws PropertyError {
+    yield avatar_details.change_avatar (this.avatar_icon);
+  }
+
+  public override Value create_value () {
+    Value v = Value (this.avatar_icon.get_type ());
+    v.set_object (this.avatar_icon);
+    return v;
+  }
+
+  // Show the avatar dialog when the avatar is clicked
+  private void on_avatar_button_clicked (Button button) {
+    var dialog = new AvatarSelector ((Gtk.Window) button.get_toplevel(), this.contact);
+    dialog.set_avatar.connect ( (icon) =>  {
+        this.avatar_icon = icon as LoadableIcon;
+        this.dirty = true;
+
+        Gdk.Pixbuf? a_pixbuf = null;
+        try {
+          var stream = (icon as LoadableIcon).load (PROFILE_SIZE, null);
+          a_pixbuf = new Gdk.Pixbuf.from_stream_at_scale (stream, PROFILE_SIZE, PROFILE_SIZE, true);
+        } catch (Error e) {
+          debug ("Couldn't load the chosen avatar: %s", e.message);
+        }
+
+        this.avatar.set_pixbuf (a_pixbuf);
+      });
+
+    dialog.run ();
+    dialog.destroy ();
+  }
+}
diff --git a/src/editor/contacts-editor-birthday-editor.vala b/src/editor/contacts-editor-birthday-editor.vala
new file mode 100644
index 0000000..f9c19ef
--- /dev/null
+++ b/src/editor/contacts-editor-birthday-editor.vala
@@ -0,0 +1,145 @@
+/*
+ * Copyright (C) 2017 Niels De Graef <nielsdegraef gmail com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+using Folks;
+using Gee;
+using Gtk;
+
+public class Contacts.Editor.BirthdayEditor : DetailsEditor<BirthdayDetails> {
+  private Label label;
+
+  private Grid date_grid;
+  private SpinButton day_spin;
+  private ComboBoxText month_combo;
+  private SpinButton year_spin;
+
+  private Button delete_button;
+
+  public override string persona_property {
+    get { return "birthday"; }
+  }
+
+  /**
+   * The day of the month (ranging from 1 to 31, depending on the month)
+   */
+  private int day {
+    get { return this.day_spin.get_value_as_int (); }
+    set { this.day_spin.set_value (value); }
+  }
+
+  /**
+   * The month (ranging from 1 to 12)
+   */
+  private int month {
+    get { return this.month_combo.get_active (); }
+    set { this.month_combo.set_active (value - 1); }
+  }
+
+  /**
+   * The year
+   */
+  private int year {
+    get { return this.year_spin.get_value_as_int (); }
+    set { this.year_spin.set_value (value); }
+  }
+
+  public BirthdayEditor (BirthdayDetails? details = null) {
+    DateTime date;
+    if (details != null && details.birthday != null)
+      date = details.birthday.to_local ();
+    else
+      date = new DateTime.now_local ();
+
+    this.label = create_label (_("Birthday"));
+    this.date_grid = create_date_widget (date);
+    this.delete_button = create_delete_button ();
+
+    this.day = date.get_day_of_month ();
+    this.month = date.get_month ();
+    this.year = date.get_year ();
+    set_day_spin_range ();
+
+    // Now that we've set the date for first time, listen to changes
+    this.day_spin.changed.connect ( () => { this.dirty = true; });
+    this.month_combo.changed.connect ( () => {
+        this.dirty = true;
+        set_day_spin_range ();
+      });
+    this.year_spin.changed.connect ( () => {
+        this.dirty = true;
+        set_day_spin_range ();
+      });
+  }
+
+  public override int attach_to_grid (Grid container_grid, int row) {
+    container_grid.attach (this.label, 0, row);
+    container_grid.attach (this.date_grid, 1, row);
+    container_grid.attach (this.delete_button, 2, row);
+
+    return 1;
+  }
+
+  public override async void save (BirthdayDetails birthday_details) throws PropertyError {
+    yield birthday_details.change_birthday (create_datetime ().to_utc ());
+  }
+
+  public override Value create_value () {
+    var result = Value (typeof (DateTime));
+    result.set_boxed (create_datetime ().to_utc ());
+    return result;
+  }
+
+  private DateTime create_datetime () {
+    return new DateTime.local (this.year, this.month + 1, this.day, 0, 0, 0);
+  }
+
+  private Grid create_date_widget (DateTime? date) {
+    var date_grid = new Grid ();
+    date_grid.column_spacing = 12;
+
+    // Day
+    this.day_spin = new SpinButton.with_range (1.0, 31.0, 1.0);
+    this.day_spin.digits = 0;
+    this.day_spin.numeric = true;
+    date_grid.add (day_spin);
+
+    // Month
+    this.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);
+        this.month_combo.append_text (month.format ("%B"));
+    }
+    this.month_combo.get_style_context ().add_class ("contacts-combo");
+    this.month_combo.hexpand = true;
+    date_grid.add (month_combo);
+
+    // Year
+    this.year_spin = new SpinButton.with_range (1800, 3000, 1);
+    this.year_spin.digits = 0;
+    this.year_spin.numeric = true;
+    date_grid.add (year_spin);
+
+    date_grid.show_all ();
+    return date_grid;
+  }
+
+  private void set_day_spin_range () {
+    var days_in_month = Date.get_days_in_month ((DateMonth) this.month, (DateYear) this.year);
+    this.day_spin.set_range (1, days_in_month);
+  }
+}
diff --git a/src/editor/contacts-editor-composite-editor.vala 
b/src/editor/contacts-editor-composite-editor.vala
new file mode 100644
index 0000000..7a400a3
--- /dev/null
+++ b/src/editor/contacts-editor-composite-editor.vala
@@ -0,0 +1,66 @@
+/*
+ * Copyright (C) 2017 Niels De Graef <nielsdegraef gmail com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+using Folks;
+using Gee;
+using Gtk;
+
+/**
+ * An interface for DetailsEditors that contain multiple child Element.
+ * It has a ChildDetails type (C), for the Details a child widget represents
+ */
+public abstract class Contacts.Editor.CompositeEditor<D, C> : DetailsEditor<D>  {
+
+  protected Gee.List<CompositeEditorChild<C>> child_editors = new LinkedList<CompositeEditorChild<C>> ();
+
+  public override int attach_to_grid (Grid container_grid, int start_row) {
+    var current_row = start_row;
+    foreach (var child_editor in this.child_editors)
+      current_row += child_editor.attach_to_grid (container_grid, current_row);
+
+    return current_row - start_row;
+  }
+
+  public override Value create_value () {
+    var children = aggregate_children ();
+    var val = Value (children.get_type ());
+    val.set_object (children);
+    return val;
+  }
+
+  protected HashSet<C> aggregate_children () {
+    var children = new HashSet<C> ();
+    foreach (var child_editor in this.child_editors)
+      children.add (child_editor.create_details ());
+    return children;
+  }
+}
+
+/**
+ * A child to a CompositeEditor.
+ */
+public abstract class Contacts.Editor.CompositeEditorChild<D> : Object {
+
+  protected MultiMap<string, string> parameters;
+
+  /**
+   * Creates the details for this CompositeEditorChild, based on the (edited) values.
+   */
+  public abstract D create_details ();
+
+  public abstract int attach_to_grid (Grid container_grid, int start_row);
+}
diff --git a/src/editor/contacts-editor-contact-editor.vala b/src/editor/contacts-editor-contact-editor.vala
new file mode 100644
index 0000000..41eea24
--- /dev/null
+++ b/src/editor/contacts-editor-contact-editor.vala
@@ -0,0 +1,259 @@
+/*
+ * Copyright (C) 2011 Alexander Larsson <alexl redhat com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+using Gtk;
+using Folks;
+using Gee;
+
+public errordomain Contacts.SaveError {
+  EMPTY_DATA,
+  NO_PRIMARY_ADDRESSBOOK,
+}
+
+[GtkTemplate (ui = "/org/gnome/Contacts/ui/contacts-contact-editor.ui")]
+public class Contacts.Editor.ContactEditor : Grid {
+
+  private const string[] DEFAULT_PROPS_NEW_CONTACT = {
+    "email-addresses",
+    "phone-numbers",
+    "postal-addresses"
+  };
+
+  // We have a form with fields for each persona.
+  private struct Form {
+    Persona? persona; // null iff new contact
+    Gee.List<Editor.DetailsEditor> editors;
+  }
+
+  // The contact we're editing, or null if creating a new one.
+  private Contact? contact;
+
+  private Store store;
+
+  // The first row of the container_grid that is empty.
+  private int next_row = 0;
+
+  private Gee.List<Form?> forms = new LinkedList<Form?> ();
+
+  private Editor.DetailsEditorFactory details_editor_factory = new Editor.DetailsEditorFactory ();
+
+  [GtkChild]
+  private Grid container_grid;
+
+  // Template subwidgets
+  [GtkChild]
+  private ScrolledWindow main_sw;
+  [GtkChild]
+  private MenuButton add_detail_button;
+  [GtkChild]
+  public Button linked_button;
+  [GtkChild]
+  public Button remove_button;
+
+  // Actions
+  private SimpleActionGroup edit_contact_actions;
+  private const GLib.ActionEntry[] action_entries = {
+    { "add-email-addresses", on_add_detail },
+    { "add-phone-numbers", on_add_detail },
+    { "add-urls", on_add_detail },
+    { "add-nickname", on_add_detail },
+    { "add-birthday", on_add_detail },
+    { "add-postal-addresses", on_add_detail },
+    { "add-notes", on_add_detail },
+  };
+
+  public bool has_birthday_row {
+    get; private set; default = false;
+  }
+
+  public bool has_nickname_row {
+    get; private set; default = false;
+  }
+
+  public bool has_notes_row {
+    get; private set; default = false;
+  }
+
+  public ContactEditor (Contact? contact, Store store) {
+    this.contact = contact;
+    this.store = store;
+
+    create_actions ();
+    init_layout ();
+
+    if (contact != null) {
+      // Load the contact's personas and their editable properties
+      bool first_persona = true;
+      foreach (var persona in contact.get_personas_for_display ()) {
+        add_widgets_for_persona (persona, first_persona);
+        first_persona = false;
+      }
+
+      // Show "Remove" and "Link" buttons
+      this.remove_button.show ();
+      this.remove_button.sensitive = this.contact.can_remove_personas ();
+      this.linked_button.show ();
+      this.linked_button.sensitive = this.contact.individual.personas.size > 1;
+    } else {
+      // Init the editor with the default properties
+      add_widgets_for_persona (null);
+    }
+  }
+
+  private void create_actions () {
+    this.edit_contact_actions = new SimpleActionGroup ();
+    this.edit_contact_actions.add_action_entries (action_entries, this);
+  }
+
+  // Initializes the basic layout
+  private void init_layout () {
+    this.container_grid.set_focus_vadjustment (this.main_sw.get_vadjustment ());
+
+    this.main_sw.get_child ().get_style_context ().add_class ("contacts-main-view");
+    this.main_sw.get_child ().get_style_context ().add_class ("view");
+
+    this.add_detail_button.get_popover ().insert_action_group ("edit", this.edit_contact_actions);
+
+    // enable/disable actions
+    var birthday_action = this.edit_contact_actions.lookup_action ("add.birthday") as SimpleAction;
+    // XXX de volgende dingen werken niet meer want die properties zijn weg :-)
+    /* bind_property ("has-birthday-row", birthday_action, "enabled", */
+    /*                BindingFlags.SYNC_CREATE | BindingFlags.INVERT_BOOLEAN); */
+
+    var nickname_action = this.edit_contact_actions.lookup_action ("add.nickname") as SimpleAction;
+    /* bind_property ("has-nickname-row", nickname_action, "enabled", */
+    /*                BindingFlags.DEFAULT | BindingFlags.SYNC_CREATE | BindingFlags.INVERT_BOOLEAN); */
+
+    var notes_action = this.edit_contact_actions.lookup_action ("add.notes") as SimpleAction;
+    /* bind_property ("has-notes-row", notes_action, "enabled", */
+    /*                BindingFlags.DEFAULT | BindingFlags.SYNC_CREATE | BindingFlags.INVERT_BOOLEAN); */
+  }
+
+  // Adds the widgets for the details in a persona
+  private void add_widgets_for_persona (Persona? persona, bool first_persona = true) {
+    var form = Form ();
+    form.persona = persona;
+    form.editors = new ArrayList<Editor.DetailsEditor> ();
+    this.forms.add (form);
+
+    if (first_persona) {
+      create_avatar_frame (form);
+      create_name_entry (form);
+      this.next_row += 3;
+    } else {
+      // Don't show the name on the default persona
+      var store_name = new Label (Contact.format_persona_store_name_for_contact (persona));
+      store_name.halign = Align.START;
+      store_name.xalign = 0.0f; // XXX don't use xalign
+      store_name.margin_start = 6;
+      this.container_grid.attach (store_name, 0, this.next_row, 2);
+      this.next_row++;
+    }
+
+    string[] writeable_props;
+    if (persona != null)
+      writeable_props = Contact.sort_persona_properties (persona.writeable_properties);
+    else
+      writeable_props = DEFAULT_PROPS_NEW_CONTACT;
+
+    foreach (var prop in writeable_props)
+      add_property (form, prop, (persona == null));
+  }
+
+  private void add_property (Form form, string prop_name, bool allow_empty = false) {
+    var editor = this.details_editor_factory.create_details_editor (form.persona, prop_name, allow_empty);
+    if (editor != null) {
+      form.editors.add (editor);
+      var rows_added = editor.attach_to_grid (this.container_grid, this.next_row);
+      this.next_row += rows_added;
+    }
+  }
+
+  // Creates the contact's current avatar, the big frame on top of the Editor
+  private void create_avatar_frame (Form form) {
+    var avatar_editor = new Editor.AvatarEditor (this.contact, form.persona as AvatarDetails);
+    avatar_editor.attach_to_grid (this.container_grid, 0);
+    form.editors.add (avatar_editor);
+  }
+
+  // Creates the big name entry on the top
+  private void create_name_entry (Form form) {
+    var full_name_editor = new Editor.FullNameEditor (this.contact, form.persona as NameDetails);
+    full_name_editor.attach_to_grid (this.container_grid, 0);
+    form.editors.add (full_name_editor);
+  }
+
+  public async Contact save_changes () throws Error {
+    if (this.contact == null) {
+      var details = new HashTable<string, Value?> (str_hash, str_equal);
+      var contacts_store = this.store;
+
+      //XXX check if name is filled in
+      var form = this.forms[0];
+      foreach (var details_editor in form.editors)
+        if (details_editor.dirty)
+          details[details_editor.persona_property] = details_editor.create_value ();
+
+      if (details.size () != 0)
+        throw new SaveError.EMPTY_DATA (_("You need to enter some data"));
+
+      if (contacts_store.aggregator.primary_store == null)
+        throw new SaveError.NO_PRIMARY_ADDRESSBOOK (_("No primary addressbook configured"));
+
+      // Create the contact
+      var primary_store = contacts_store.aggregator.primary_store;
+      var persona = yield Contact.create_primary_persona_for_details (primary_store, details);
+
+      return contacts_store.find_contact_with_persona (persona);
+    }
+
+    //XXX check for empty values
+    warning("SAVING WITH %d forms", this.forms.size);
+    foreach (var form in this.forms) {
+      warning("FORM WITH %d editors", form.editors.size);//XXX
+      foreach (var details_editor in form.editors) {
+        debug("FORM EDITOR %s (dirty: %s)", details_editor.get_type().name(), 
(details_editor.dirty).to_string());//XXX
+        if (details_editor.dirty)
+          yield details_editor.save_to_persona (form.persona);
+      }
+    }
+    return this.contact;
+  }
+
+  private void on_add_detail (SimpleAction action, Variant? parameter) {
+    var tok = action.name.split ("-", 2);
+
+    // The name of the property we're adding
+    var property = tok[1];
+
+    // Get the form for the primary persona (if any)
+    Form? form = null;
+    if (contact != null) {
+        var primary_persona = contact.find_primary_persona ();
+        foreach (var f in this.forms) {
+            if (f.persona == primary_persona) {
+                form = f;
+                break;
+            }
+        }
+    }
+    form = form ?? this.forms[0]; // Take the first form available
+
+    // Add the property to the form
+    add_property (form, property, true);
+  }
+}
diff --git a/src/editor/contacts-editor-details-editor-factory.vala 
b/src/editor/contacts-editor-details-editor-factory.vala
new file mode 100644
index 0000000..854846d
--- /dev/null
+++ b/src/editor/contacts-editor-details-editor-factory.vala
@@ -0,0 +1,101 @@
+/*
+ * Copyright (C) 2017 Niels De Graef <nielsdegraef gmail com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+using Folks;
+using Gee;
+using Gtk;
+
+/**
+ * A Factory for DetailEditors.
+ */
+public class Contacts.Editor.DetailsEditorFactory : Object {
+
+  /**
+   * Creates a DetailEditor for a specific property, given a persona.
+   * @return The newly created editor, or null if no editor was created.
+   */
+  public DetailsEditor? create_details_editor (Persona? p, string prop_name, bool allow_empty = false) {
+    switch (prop_name) {
+      case "birthday":
+        return create_birthday_editor (p, allow_empty);
+      case "email-addresses":
+        return create_emails_editor (p, allow_empty);
+      case "nickname":
+        return create_nickname_editor (p, allow_empty);
+      case "notes":
+        return create_notes_editor (p, allow_empty);
+      case "phone-numbers":
+        return create_phones_editor (p, allow_empty);
+      case "postal-addresses":
+        return create_addresses_editor (p, allow_empty);
+      case "urls":
+        return create_urls_editor (p, allow_empty);
+      default:
+        debug ("Unsupported property name \"%s\"", prop_name);
+        return null;
+    }
+  }
+
+  public BirthdayEditor? create_birthday_editor (Persona? p, bool allow_empty) {
+    var birthday_details = p as BirthdayDetails;
+    if (!allow_empty && (birthday_details == null || birthday_details.birthday == null))
+      return null;
+    return new BirthdayEditor (p as BirthdayDetails);
+  }
+
+  public EmailsEditor? create_emails_editor (Persona? p, bool allow_empty) {
+    var email_details = p as EmailDetails;
+    if (!allow_empty && (email_details == null || email_details.email_addresses.is_empty))
+      return null;
+    return new EmailsEditor (email_details);
+  }
+
+  public NicknameEditor? create_nickname_editor (Persona? p, bool allow_empty) {
+    var name_details = p as NameDetails;
+    if (!allow_empty && (name_details == null || name_details.nickname == null || name_details.nickname == 
""))
+      return null;
+    return new NicknameEditor (name_details);
+  }
+
+  public NotesEditor? create_notes_editor (Persona? p, bool allow_empty) {
+    var note_details = p as NoteDetails;
+    if (!allow_empty && (note_details == null || note_details.notes.is_empty))
+      return null;
+    return new NotesEditor (note_details);
+  }
+
+  public PhonesEditor? create_phones_editor (Persona? p, bool allow_empty) {
+    var phone_details = p as PhoneDetails;
+    if (!allow_empty && (phone_details == null || phone_details.phone_numbers.is_empty))
+      return null;
+    return new PhonesEditor (phone_details);
+  }
+
+  public AddressesEditor? create_addresses_editor (Persona? p, bool allow_empty) {
+    var address_details = p as PostalAddressDetails;
+    if (!allow_empty && (address_details == null || address_details.postal_addresses.is_empty))
+      return null;
+    return new AddressesEditor (address_details);
+  }
+
+  public UrlsEditor? create_urls_editor (Persona? p, bool allow_empty) {
+    var url_details = p as UrlDetails;
+    if (!allow_empty && (url_details == null || url_details.urls.is_empty))
+      return null;
+    return new UrlsEditor (url_details);
+  }
+}
diff --git a/src/editor/contacts-editor-details-editor.vala b/src/editor/contacts-editor-details-editor.vala
new file mode 100644
index 0000000..1301d33
--- /dev/null
+++ b/src/editor/contacts-editor-details-editor.vala
@@ -0,0 +1,136 @@
+/*
+ * Copyright (C) 2017 Niels De Graef <nielsdegraef gmail com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+using Folks;
+using Gee;
+using Gtk;
+
+/**
+ * A DetailsEditor is an Element that can handle a specific property of a Persona.
+ */
+public abstract class Contacts.Editor.DetailsEditor<D> : Object {
+
+  /**
+   * Fired when the user asks to remove the EditorElement.
+   */
+  public signal void removed ();
+
+  /**
+   * Returns whether the DetailsEditor has unsaved changes.
+   */
+  public bool dirty { get; protected set; default = false; }
+
+  /**
+   * Returns the Persona property (well, the string) this EditorElement takes care of.
+   */
+  public abstract string persona_property { get; }
+
+  /**
+   * Attaches the element to the grid (possibly over multiple rows).
+   *
+   * @param container_grid The grid to which the element should be added.
+   * @param start_row The row at which we should start editing.
+   *
+   * @return The amount of rows that were added to the grid by this EditorElement.
+   */
+  public abstract int attach_to_grid (Grid container_grid, int start_row);
+
+  /**
+   * Saves the (edited) value to the Details object.
+   */
+  public abstract async void save (D details) throws PropertyError;
+
+  public async void save_to_persona (Persona persona) throws PropertyError {
+    yield save ((D) persona);
+  }
+
+  /**
+   * Returns a Value that can be used for methods like Folks.PersonaStore.add_persona_from_details()
+   */
+  public abstract Value create_value ();
+
+  /* Helper methods for building
+   ----------------------------- */
+  public TypeCombo create_type_combo (TypeSet type_set, AbstractFieldDetails? details = null) {
+    var combo = new TypeCombo (type_set);
+    combo.hexpand = false;
+    if (details != null)
+      combo.set_active (details);
+    combo.valign = Align.CENTER; // XXX why not START?
+    combo.changed.connect (() => { this.dirty = true; });
+    combo.show ();
+
+    return combo;
+  }
+
+  public Label create_label (string text) {
+    var label = new Label (text);
+    label.hexpand = false;
+    label.valign = Align.START;
+    label.halign = Align.END;
+    label.margin_end = 6;
+    label.get_style_context ().add_class ("dim-label");
+    label.show ();
+
+    return label;
+  }
+
+  public Entry create_entry (string? text, string? placeholder = null) {
+    var entry = new Entry ();
+    entry.hexpand = true;
+    if (text != null)
+      entry.text = text;
+    if (placeholder != null)
+      entry.placeholder_text = placeholder;
+    entry.show ();
+
+    entry.changed.connect (() => { this.dirty = true; });
+
+    return entry;
+  }
+
+  // XXX scrolledwindow?
+  public ScrolledWindow create_textview (string? text = null) {
+    var sw = new ScrolledWindow (null, null);
+    sw.shadow_type = ShadowType.OUT;
+    sw.set_size_request (-1, 100);
+
+    var value_text = new TextView ();
+    if (text != null)
+      value_text.get_buffer ().set_text (text);
+    value_text.hexpand = true;
+
+    sw.add (value_text);
+    sw.show_all ();
+
+    value_text.get_buffer ().changed.connect (() => { this.dirty = true; });
+
+    /* return value_text; */
+    return sw;
+  }
+
+  public Button create_delete_button () {
+    var delete_button = new Button.from_icon_name ("edit-delete-symbolic");
+    delete_button.valign = Align.START;
+    delete_button.get_accessible ().set_name (_("Delete field"));
+    delete_button.get_style_context ().add_class ("flat");
+    delete_button.clicked.connect (() => removed ());
+    delete_button.show ();
+
+    return delete_button;
+  }
+}
diff --git a/src/editor/contacts-editor-emails-editor.vala b/src/editor/contacts-editor-emails-editor.vala
new file mode 100644
index 0000000..b21e07b
--- /dev/null
+++ b/src/editor/contacts-editor-emails-editor.vala
@@ -0,0 +1,74 @@
+/*
+ * Copyright (C) 2017 Niels De Graef <nielsdegraef gmail com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+using Folks;
+using Gee;
+using Gtk;
+
+public class Contacts.Editor.EmailsEditor : CompositeEditor<EmailDetails, EmailFieldDetails> {
+
+  public override string persona_property {
+    get { return "email-addresses"; }
+  }
+
+  public EmailsEditor (EmailDetails? details = null) {
+    if (details != null) {
+      var email_fields = Contact.sort_fields<EmailFieldDetails>(details.email_addresses);
+      foreach (var email_field_detail in email_fields)
+        this.child_editors.add (new EmailEditor (this, email_field_detail));
+    } else {
+      // No emails were passed on => make a single personal email address
+      this.child_editors.add (new EmailEditor (this, null, "PERSONAL"));
+    }
+  }
+
+  public override async void save (EmailDetails email_details) throws PropertyError {
+    yield email_details.change_email_addresses (aggregate_children ());
+  }
+
+  /**
+   * Deals with a single email address field.
+   */
+  public class EmailEditor : CompositeEditorChild<EmailFieldDetails> {
+    private TypeCombo type_combo;
+    private Entry email_entry;
+    private Button delete_button;
+
+    public EmailEditor (EmailsEditor parent, EmailFieldDetails? details = null, string? type = null) {
+      this.type_combo = parent.create_type_combo (TypeSet.email, details);
+      string? email = (details != null)? details.value : null;
+      this.email_entry = parent.create_entry (email, _("Add email"));
+      this.delete_button = parent.create_delete_button ();
+
+      if (type != null)
+        this.type_combo.set_to (type);
+    }
+
+    public override int attach_to_grid (Grid container_grid, int row) {
+      container_grid.attach (this.type_combo, 0, row);
+      container_grid.attach (this.email_entry, 1, row);
+      container_grid.attach (this.delete_button, 2, row);
+
+      return 1;
+    }
+
+    public override EmailFieldDetails create_details () {
+      // XXX parameters
+      return new EmailFieldDetails (this.email_entry.text, null);
+    }
+  }
+}
diff --git a/src/editor/contacts-editor-full-name-editor.vala 
b/src/editor/contacts-editor-full-name-editor.vala
new file mode 100644
index 0000000..32622a9
--- /dev/null
+++ b/src/editor/contacts-editor-full-name-editor.vala
@@ -0,0 +1,50 @@
+/*
+ * Copyright (C) 2017 Niels De Graef <nielsdegraef gmail com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+using Folks;
+using Gee;
+using Gtk;
+
+public class Contacts.Editor.FullNameEditor : DetailsEditor<NameDetails> {
+
+  private Entry name_entry;
+
+  public override string persona_property {
+    get { return "full-name"; }
+  }
+
+  public FullNameEditor (Contact? contact = null, NameDetails? details = null) {
+    string? name = (contact != null)? contact.individual.display_name : null;
+    this.name_entry = create_entry (name, _("Add name"));
+    this.name_entry.valign = Align.CENTER;
+  }
+
+  public override int attach_to_grid (Grid container_grid, int row) {
+    container_grid.attach (this.name_entry, 1, row, 2, 3);
+    return 0;
+  }
+
+  public override async void save (NameDetails name_details) throws PropertyError {
+       yield name_details.change_full_name (this.name_entry.text);
+  }
+
+  public override Value create_value () {
+    Value v = Value (typeof (string));
+    v.set_string (this.name_entry.text);
+    return v;
+  }
+}
diff --git a/src/editor/contacts-editor-nickname-editor.vala b/src/editor/contacts-editor-nickname-editor.vala
new file mode 100644
index 0000000..2ad0093
--- /dev/null
+++ b/src/editor/contacts-editor-nickname-editor.vala
@@ -0,0 +1,55 @@
+/*
+ * Copyright (C) 2017 Niels De Graef <nielsdegraef gmail com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+using Folks;
+using Gee;
+using Gtk;
+
+public class Contacts.Editor.NicknameEditor : DetailsEditor<NameDetails> {
+  private Label label;
+  private Entry nickname_entry;
+  private Button delete_button;
+
+  public override string persona_property {
+    get { return "nickname"; }
+  }
+
+  public NicknameEditor (NameDetails? details = null) {
+    this.label = create_label (_("Nickname"));
+    string? nickname = (details != null)? details.nickname : null;
+    this.nickname_entry = create_entry (nickname);
+    this.delete_button = create_delete_button ();
+  }
+
+  public override int attach_to_grid (Grid container_grid, int row) {
+    container_grid.attach (this.label, 0, row);
+    container_grid.attach (this.nickname_entry, 1, row);
+    container_grid.attach (this.delete_button, 2, row);
+
+    return 1;
+  }
+
+  public override async void save (NameDetails name_details) throws PropertyError {
+    yield name_details.change_nickname (this.nickname_entry.text);
+  }
+
+  public override Value create_value () {
+    var result = Value (typeof (string));
+    result.set_string (nickname_entry.text);
+    return result;
+  }
+}
diff --git a/src/editor/contacts-editor-notes-editor.vala b/src/editor/contacts-editor-notes-editor.vala
new file mode 100644
index 0000000..d177a54
--- /dev/null
+++ b/src/editor/contacts-editor-notes-editor.vala
@@ -0,0 +1,75 @@
+/*
+ * Copyright (C) 2017 Niels De Graef <nielsdegraef gmail com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+using Folks;
+using Gee;
+using Gtk;
+
+/**
+ * Deals with multiple "Notes"
+ */
+public class Contacts.Editor.NotesEditor : CompositeEditor<NoteDetails, NoteFieldDetails> {
+
+  public override string persona_property {
+    get { return "notes"; }
+  }
+
+  public NotesEditor (NoteDetails? details = null) {
+    if (details != null) {
+      foreach (var note_field_detail in details.notes)
+        this.child_editors.add (new NoteEditor (this, note_field_detail));
+    } else {
+      // No notes were passed on => make a single blank editor
+      this.child_editors.add (new NoteEditor (this));
+    }
+  }
+
+  public override async void save (NoteDetails note_details) throws PropertyError {
+    yield note_details.change_notes (aggregate_children ());
+  }
+
+  /**
+   * Deals with a single "Notes" field.
+   */
+  public class NoteEditor : CompositeEditorChild<NoteFieldDetails> {
+    private Label label;
+    private ScrolledWindow note_textview;
+    private Button delete_button;
+
+    public NoteEditor (NotesEditor parent, NoteFieldDetails? details = null) {
+      this.label = parent.create_label (_("Note"));
+      var text = (details != null)? details.value : null;
+      this.note_textview = parent.create_textview (text);
+      this.delete_button = parent.create_delete_button ();
+    }
+
+    public override int attach_to_grid (Grid container_grid, int row) {
+      container_grid.attach (this.label, 0, row);
+      container_grid.attach (this.note_textview, 1, row);
+      container_grid.attach (this.delete_button, 2, row);
+
+      return 1;
+    }
+
+    public override NoteFieldDetails create_details () {
+      // XXX parameters
+      // XXX scrolledwindow
+      return new NoteFieldDetails ("test niels", null);
+      /* return new NoteFieldDetails (this.note_textview.buffer.text, null); */
+    }
+  }
+}
diff --git a/src/editor/contacts-editor-phones-editor.vala b/src/editor/contacts-editor-phones-editor.vala
new file mode 100644
index 0000000..e601693
--- /dev/null
+++ b/src/editor/contacts-editor-phones-editor.vala
@@ -0,0 +1,76 @@
+/*
+ * Copyright (C) 2017 Niels De Graef <nielsdegraef gmail com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+using Folks;
+using Gee;
+using Gtk;
+
+public class Contacts.Editor.PhonesEditor : CompositeEditor<PhoneDetails, PhoneFieldDetails> {
+
+  public override string persona_property {
+    get { return "phone-numbers"; }
+  }
+
+  public PhonesEditor (PhoneDetails? details = null) {
+    if (details != null) {
+      var phone_fields = Contact.sort_fields<PhoneFieldDetails>(details.phone_numbers);
+      foreach (var phone_nr_detail in phone_fields)
+        this.child_editors.add (new PhoneEditor (this, phone_nr_detail));
+    } else {
+      // No phones were passed on => make a single cell phone number
+      this.child_editors.add (new PhoneEditor (this, null, "CELL"));
+    }
+  }
+
+  public override async void save (PhoneDetails phone_details) throws PropertyError {
+    yield phone_details.change_phone_numbers (aggregate_children ());
+  }
+
+  public class PhoneEditor : CompositeEditorChild<PhoneFieldDetails> {
+    private TypeCombo type_combo;
+    private Entry phone_entry;
+    private Button delete_button;
+
+    public PhoneEditor (PhonesEditor parent, PhoneFieldDetails? details = null, string? type = null) {
+      this.type_combo = parent.create_type_combo (TypeSet.phone, details);
+      string? phone_nr = (details != null)? details.value : null;
+      this.phone_entry = parent.create_entry (phone_nr, _("Add number"));
+      this.delete_button = parent.create_delete_button ();
+
+      if (details != null && details.parameters != null)
+        this.parameters = details.parameters;
+      else
+        this.parameters = new HashMultiMap<string, string> ();
+
+      if (type != null)
+        this.type_combo.set_to (type);
+    }
+
+    public override int attach_to_grid (Grid container_grid, int row) {
+      container_grid.attach (this.type_combo, 0, row);
+      container_grid.attach (this.phone_entry, 1, row);
+      container_grid.attach (this.delete_button, 2, row);
+
+      return 1;
+    }
+
+    public override PhoneFieldDetails create_details () {
+      this.type_combo.update_type_parameter (this.parameters);
+      return new PhoneFieldDetails (this.phone_entry.text, this.parameters);
+    }
+  }
+}
diff --git a/src/editor/contacts-editor-urls-editor.vala b/src/editor/contacts-editor-urls-editor.vala
new file mode 100644
index 0000000..15fad62
--- /dev/null
+++ b/src/editor/contacts-editor-urls-editor.vala
@@ -0,0 +1,68 @@
+/*
+ * Copyright (C) 2017 Niels De Graef <nielsdegraef gmail com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+using Folks;
+using Gee;
+using Gtk;
+
+public class Contacts.Editor.UrlsEditor : CompositeEditor<UrlDetails, UrlFieldDetails> {
+
+  public override string persona_property {
+    get { return "urls"; }
+  }
+
+  public UrlsEditor (UrlDetails? details = null) {
+    if (details != null) {
+      /* var url_fields = Contact.sort_fields<UrlFieldDetails>(details.urls); */
+      /* foreach (var url_field_detail in url_fields) */
+      foreach (var url_field_detail in details.urls)
+        this.child_editors.add (new UrlEditor (this, url_field_detail));
+    } else {
+      // No urls were passed on => make a single blank editor
+      this.child_editors.add (new UrlEditor (this));
+    }
+  }
+
+  public override async void save (UrlDetails url_details) throws PropertyError {
+    yield url_details.change_urls (aggregate_children ());
+  }
+
+  public class UrlEditor : CompositeEditorChild<UrlFieldDetails> {
+    private Label label;
+    private Entry url_entry;
+    private Button delete_button;
+
+    public UrlEditor (UrlsEditor parent, UrlFieldDetails? details = null) {
+      this.label = parent.create_label (_("Website"));
+      this.url_entry = parent.create_entry ((details != null)? details.value : null);
+      this.delete_button = parent.create_delete_button ();
+    }
+
+    public override int attach_to_grid (Grid container_grid, int row) {
+      container_grid.attach (this.label, 0, row);
+      container_grid.attach (this.url_entry, 1, row);
+      container_grid.attach (this.delete_button, 2, row);
+
+      return 1;
+    }
+
+    public override UrlFieldDetails create_details () {
+      // XXX parameters
+      return new UrlFieldDetails (this.url_entry.text, null);
+    }
+  }
+}
diff --git a/src/meson.build b/src/meson.build
index fc20c72..b55f85e 100644
--- a/src/meson.build
+++ b/src/meson.build
@@ -6,11 +6,24 @@ install_data('org.gnome.Contacts.gschema.xml',
 
 # The gnome-contacts binary
 contacts_vala_sources = [
+  'editor/contacts-editor-addresses-editor.vala',
+  'editor/contacts-editor-avatar-editor.vala',
+  'editor/contacts-editor-birthday-editor.vala',
+  'editor/contacts-editor-composite-editor.vala',
+  'editor/contacts-editor-contact-editor.vala',
+  'editor/contacts-editor-details-editor-factory.vala',
+  'editor/contacts-editor-details-editor.vala',
+  'editor/contacts-editor-emails-editor.vala',
+  'editor/contacts-editor-full-name-editor.vala',
+  'editor/contacts-editor-nickname-editor.vala',
+  'editor/contacts-editor-notes-editor.vala',
+  'editor/contacts-editor-phones-editor.vala',
+  'editor/contacts-editor-urls-editor.vala',
+
   'contacts-accounts-list.vala',
   'contacts-app.vala',
   'contacts-avatar.vala',
   'contacts-avatar-selector.vala',
-  'contacts-contact-editor.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]