diff options
author | Niels De Graef <nielsdegraef@gmail.com> | 2018-12-01 17:42:24 +0100 |
---|---|---|
committer | Niels De Graef <nielsdegraef@gmail.com> | 2019-03-19 20:28:36 +0100 |
commit | e9bbc8e4f79436a167ee4197c72e4c9db64f0251 (patch) | |
tree | ffcfd049a22256c65930806129967abc3895065b | |
parent | 332d438f1bc1d746106c9a0873a816f2081cd6be (diff) | |
download | gnome-contacts-wip/nielsdg/plugins-for-form-fields.tar.gz |
WIP: Rewrite the ContactEditor/ContactSheetwip/nielsdg/plugins-for-form-fields
Rather than using a large if-else construct with several copy-pasted
chunks, we create a `PersonaProperty` class, which represents a property
instance of a persona. These object can then create an appropriate
widget that allows the user to view, edit or remove that specific
property.
As a consequence, we can extract and simplify parts of the code on how
to keep the list of properties saved: we can just have a `GListModel` of
said property instances in the common `ContactForm` parent class. The
subclasses `ContactSheet` and `ContactEditor` can then deal with this
list appropriately.
In the long term, we can simplify this probably even more; for now, it's
good enough to have this over the old code, which was getting a bit
unmaintainable.
-rw-r--r-- | data/ui/contacts-contact-form.ui | 14 | ||||
-rw-r--r-- | data/ui/style.css | 4 | ||||
-rw-r--r-- | src/contacts-contact-editor.vala | 890 | ||||
-rw-r--r-- | src/contacts-contact-form.vala | 120 | ||||
-rw-r--r-- | src/contacts-contact-pane.vala | 60 | ||||
-rw-r--r-- | src/contacts-contact-sheet.vala | 242 | ||||
-rw-r--r-- | src/contacts-contact.vala | 153 | ||||
-rw-r--r-- | src/contacts-persona-property.vala | 1106 | ||||
-rw-r--r-- | src/contacts-type-combo.vala | 1 | ||||
-rw-r--r-- | src/contacts-type-descriptor.vala | 85 | ||||
-rw-r--r-- | src/contacts-utils.vala | 68 | ||||
-rw-r--r-- | src/meson.build | 1 |
12 files changed, 1528 insertions, 1216 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 3398bf0..64e7c2b 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,65 @@ 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_name in p.writeable_properties) { + var prop = add_edit_row (p, prop_name); + if (prop != null) + add_property (prop); } } } 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 ()); + foreach (var prop_name in DEFAULT_PROPS_NEW_CONTACT) { + var tok = prop_name.split ("."); + var prop = add_edit_row (null, tok[0], true, tok[1].up ()); + if (prop != null) + add_property (prop); } - 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; + this.focus_widget = this.name_widget; } - 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 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; - } - - 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"; + PersonaProperty? 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 || EmailsProperty.should_show (p)) + return new EditableEmailsProperty (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 || PhoneNrsProperty.should_show (p)) + return new EditablePhoneNrsProperty (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 || UrlsProperty.should_show (p)) + return new EditableUrlsProperty (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 || NicknameProperty.should_show (p)) + return new EditableNicknameProperty (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 || BirthdayProperty.should_show (p)) + return new EditableBirthdayProperty (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 || NotesProperty.should_show (p)) + return new EditableNotesProperty (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 || PostalAddressesProperty.should_show (p)) + return new EditablePostalAddressesProperty (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 +152,82 @@ 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.props.get_n_items (); i++) { + var prop = this.props.get_item (i) as EditableProperty; + if (prop != null) { + yield prop.save_changes (); + debug ("Successfully saved property '%s'", prop.property_name); } } - return props_set; + if (name_changed ()) { + var v = get_full_name_value (); + yield this.contact.set_individual_property ("full-name", v); + debug ("Successfully saved name"); + /*XXX*/ + /* display_name_changed (v.get_string ()); */ + } + + if (avatar_changed ()) { + var v = get_avatar_value (); + yield this.contact.set_individual_property ("avatar", v); + debug ("Successfully saved avatar"); + } } - 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; - } + 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.props.get_n_items (); i++) { + var prop = this.props.get_item (i) as EditableProperty; + if (prop != null) + details[prop.property_name] = prop.create_value (); } - 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; - } + return details; + } + + public void add_new_row_for_property (Persona? p, string prop_name, string? type = null) { + // First check if the prop doesn't exist already + var prop = get_field (p, prop_name); + debug ("Tryig to add prop for property: %s, existing? %p", prop_name, prop); + + if (prop != null) { + // XXX check if we can add, or focus existing + return; } - 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 (); + + prop = add_edit_row (p, prop_name, true, type); + if (prop != null) + add_property (prop); } // 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 +236,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 +254,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.vala b/src/contacts-contact-form.vala index f44a2fb..3366e3e 100644 --- a/src/contacts-contact-form.vala +++ b/src/contacts-contact-form.vala @@ -49,42 +49,128 @@ public abstract class Contacts.ContactForm : Grid { [GtkChild] protected Grid container_grid; - protected int last_row = 0; + + [GtkChild] + protected ListBox form_container; + protected GLib.ListStore props = new GLib.ListStore (typeof (PersonaProperty)); + + 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.props, create_row); + this.form_container.set_header_func (create_persona_store_header); + } + + private Gtk.Widget create_row (Object object) { + unowned PersonaProperty property = (PersonaProperty) object; + return property.create_row (this.labels_sizegroup, this.values_sizegroup, this.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.prop.persona == null || previous.prop.persona == null) + return; + if (current.prop.persona == previous.prop.persona) + return; + + var label = create_persona_store_label (current.prop.persona); + row.set_header (label); + } + + private static int compare_persona_properties (Object obj_a, Object obj_b) { + unowned PersonaProperty a = (PersonaProperty) obj_a; + unowned PersonaProperty b = (PersonaProperty) obj_b; + + // First compare personas + var persona_comparison = Utils.compare_personas_on_store (a.persona, b.persona); + if (persona_comparison != 0) + return persona_comparison; + + // 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; - if (b == prop) - return 1; - } + // Next, check for the VCard PREF attribute + // XXX TODO + return 0; + } - 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; - var sorted_props = new ArrayList<string> (); - foreach (var s in props) - sorted_props.add (s); + if (b == prop) + return 1; + } - sorted_props.sort ((owned) compare_properties); - return sorted_props.to_array (); + 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 PersonaProperty? get_property_for_name (string name) { + for (uint i = 0; i < this.props.get_n_items(); i++) { + var prop = (PersonaProperty) this.props.get_item (i); + if (prop.property_name == name) + return prop; + } + + return null; + } + + protected void add_property (PersonaProperty property) { + this.props.insert_sorted (property, compare_persona_properties); + } + + protected PersonaProperty? get_field (Persona? persona, string property_name) { + for (int i = 0; i < this.props.get_n_items(); i++) { + var prop = (PersonaProperty) this.props.get_item (i); + if (prop.persona == persona && prop.property_name == property_name) + return prop; + } + + return null; + } + + 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..4be8e89 100644 --- a/src/contacts-contact-pane.vala +++ b/src/contacts-contact-pane.vala @@ -184,8 +184,8 @@ public class Contacts.ContactPane : Stack { if (tok[0] == "add") { editor.add_new_row_for_property (contact.find_primary_persona (), - tok[1], - tok.length > 2 ? tok[2].up () : null); + tok[1], + tok.length > 2 ? tok[2].up () : null); } } @@ -221,10 +221,21 @@ 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) { + warning ("Editor:saving changes"); + editor.save_changes.begin ((obj, res) => { + try { + editor.save_changes.end (res); + } catch (Error e) { + critical(e.message); // XXX + show_message (e.message); + } + }); + } remove_contact_editor (); @@ -234,35 +245,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 +255,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..cbdb5fd 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_name in ContactForm.SORTED_PROPERTIES) { + var prop = add_row_for_property (p, prop_name); + if (prop != null) + add_property (prop); } } 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.props.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 PersonaProperty? add_row_for_property (Persona persona, string property_name) { + switch (property_name) { case "email-addresses": - add_emails (persona); + if (EmailsProperty.should_show (persona)) + return new EmailsProperty (persona); break; case "phone-numbers": - add_phone_nrs (persona); - break; - case "im-addresses": - add_im_addresses (persona); + if (PhoneNrsProperty.should_show (persona)) + return new PhoneNrsProperty (persona); break; case "urls": - add_urls (persona); + if (UrlsProperty.should_show (persona)) + return new UrlsProperty (persona); break; case "nickname": - add_nickname (persona); + if (NicknameProperty.should_show (persona)) + return new NicknameProperty (persona); break; case "birthday": - add_birthday (persona); + if (BirthdayProperty.should_show (persona)) + return new BirthdayProperty (persona); break; case "notes": - add_notes (persona); + if (NotesProperty.should_show (persona)) + return new NotesProperty (persona); break; case "postal-addresses": - add_postal_addresses (persona); + if (PostalAddressesProperty.should_show (persona)) + return new PostalAddressesProperty (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-persona-property.vala b/src/contacts-persona-property.vala new file mode 100644 index 0000000..3630149 --- /dev/null +++ b/src/contacts-persona-property.vala @@ -0,0 +1,1106 @@ +/* + * 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 PersonaProperty is an abstraction of the property of a {@link Folks.Persona}. + * + * Since the contents of a property often isn't allowed to contain invalid + * information -such as an empty value-, a PersonaProperty allows us to deal + * with missing or invalid data as the user is inputting it to the UI. + * + * It also gives you a convenient APIT to show the property in the UI, using + * the create_row() method, which works well with a ListBox and an underlying + * GListModel (for example in a {@link Contacts.ContactForm}). + */ +public abstract class Contacts.PersonaProperty : Object { + + /** + * The {@link Folks.Persona} this property belongs to. + */ + public Persona? persona { get; construct set; default = null; } + + /** + * The canonical name of this property, as used by libfolks. + * Note that this often maps to a lower-case version of the VCard property. + */ + public string property_name { get; construct set; } + + /** + * Specifies that the property is filled, i.e. that no extra information can be added anymore. + * For example, a Persona can only have a single birthday. + */ + public abstract bool filled { get; } + + /** + * 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) { + var row = new PropertyWidget (this, label_group, value_group, actions_group); + row.margin_top = 18; + row.hexpand = true; + + // The subclass is responsible for making the appropriate widgets + create_widgets (row); + + row.show_all (); + + return row; + } + + /** + * Each subclass is responsible for creating the necessary UI and + * incorporating it into the given {@link PropertyWidget}. + */ + protected abstract void create_widgets (PropertyWidget prop_widget); +} + +public abstract class Contacts.AggregatedPersonaProperty : PersonaProperty { + + //XXX + /* protected ListModel elements; */ + + // By default, one can always add as many elements to this property as possible + public override bool filled { get { return false; } } + + /** + * The number of elements in this property. For example, this will be 2 for 2 email addresses. + */ + public abstract int n_elements { get; } +} + +public interface Contacts.EditableProperty : PersonaProperty { + + /* public abstract void add_empty (string? hint); */ + + /** + * 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`. + * + * XXX TODO FIXME: this will time out and fail in Edsf personas if the property didn't change. + * Either we need to fix this in folks or make *absolutely* sure the values changed + */ + public abstract async void save_changes () throws PropertyError; + + protected bool check_if_equal (Collection<AbstractFieldDetails> old_field_details, + Collection<AbstractFieldDetails> new_field_details) { + // Compare FieldDetails (maybe use equal_static? using a Set) + foreach (var old_field_detail in old_field_details) { + bool got_match = false; + foreach (var new_field_detail in new_field_details) { + // Check if the values are equal + if (!old_field_detail.values_equal (new_field_detail)) + continue; + + // We can't use AbstractFieldDetails.parameters_equal here, + // since custom labels should be compared case-sensitive, while standard + // ones shouldn't really. + + // Only compare the fields we know about => at this point only the + // type-related ones + if (!TypeDescriptor.check_type_parameters_equal (old_field_detail.parameters, + new_field_detail.parameters)) + continue; + + got_match = true; + } + + if (!got_match) + return false; + } + + return true; + } +} + +/** + * Represents a way of showing a given {@link Contacts.PersonaProperty} in a + * {@link Gtk.ListBox}, such as one would find in a ContactForm. + */ +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 prop +/// XXX maybe only store the persona? + public weak PersonaProperty prop { 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 (PersonaProperty parent, SizeGroup labels, SizeGroup values, SizeGroup actions) { + Object (prop: 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.NicknameProperty : PersonaProperty { + + protected string nickname = ""; + + public override bool filled { get { return this.nickname != ""; } } + + public NicknameProperty (Persona? persona) { + Object ( + property_name: "nickname", + persona: persona + ); + + if (persona != null) + 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 prop_widget) { + var type_label = prop_widget.create_type_label (_("Nickname")); + var value_label = prop_widget.create_value_label (this.nickname); + prop_widget.add_row (type_label, value_label); + } +} + +public class Contacts.EditableNicknameProperty : NicknameProperty, EditableProperty { + + private bool deleted { get; set; default = false; } + + public override bool filled { get { return base.filled && !this.deleted; } } + + public EditableNicknameProperty (Persona? persona) { + base (persona); + } + + protected override void create_widgets (PropertyWidget prop_widget) { + var type_label = prop_widget.create_type_label (_("Nickname")); + var nickname_entry = prop_widget.create_value_entry (this.nickname); + nickname_entry.changed.connect ((editable) => { + this.nickname = editable.get_chars (); + }); + + var delete_button = prop_widget.create_delete_button (_("Remove nickname")); + delete_button.clicked.connect ((b) => { this.deleted = true; }); + prop_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); + + if (this.deleted) { + yield ((NameDetails) this.persona).change_nickname (""); + return; + } + + if (this.nickname == ((NameDetails) this.persona).nickname) + return; + + yield ((NameDetails) this.persona).change_nickname (this.nickname); + } +} + +public class Contacts.BirthdayProperty : PersonaProperty { + + // In local timezone + protected DateTime birthday = new DateTime.now_local (); + + // this.birthday is never null, so it is always filled + public override bool filled { get { return true; } } + + public BirthdayProperty (Persona? persona) { + Object ( + property_name: "birthday", + persona: persona + ); + + if (persona != null) { + 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 prop_widget) { + var type_label = prop_widget.create_type_label (_("Birthday")); + var value_label = prop_widget.create_value_label (this.birthday.format ("%x")); + prop_widget.add_row (type_label, value_label); + } +} + +public class Contacts.EditableBirthdayProperty : BirthdayProperty, EditableProperty { + + private bool deleted { get; set; default = false; } + + public override bool filled { get { return !this.deleted; } } + + public EditableBirthdayProperty (Persona? persona) { + base (persona); + } + + protected override void create_widgets (PropertyWidget prop_widget) { + var type_label = prop_widget.create_type_label (_("Birthday")); + var birthday_entry = create_date_widget (); + var delete_button = prop_widget.create_delete_button (_("Remove birthday")); + delete_button.clicked.connect ((b) => { this.deleted = true; }); + prop_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 () { + // Check if it got deleted + if (this.birthday == null) + return null; + + 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); + + // The birthday property got deleted + if (this.birthday == null) { + yield ((BirthdayDetails) this.persona).change_birthday (null); + return; + } + + var new_birthday = this.birthday.to_utc (); + if (new_birthday == ((BirthdayDetails) this.persona).birthday) + return; + + yield ((BirthdayDetails) this.persona).change_birthday (new_birthday); + } +} + +public class Contacts.PhoneNrsProperty : AggregatedPersonaProperty { + + protected class PhoneNr : Object { + public TypeDescriptor type_descr { get; set; } + public string number { get; set; default = ""; } + public MultiMap<string, string>? parameters { get; set; default = null; } + public bool deleted { get; set; default = false; } + + public PhoneNr.dummy (string type_str) { + Object (type_descr: TypeSet.phone.lookup_descriptor_in_store (type_str)); + } + + public PhoneNr (PhoneFieldDetails details) { + Object (type_descr: TypeSet.phone.lookup_descriptor_for_field_details (details), + number: details.value, + parameters: details.parameters); + } + } + + protected Gee.List<PhoneNr?> phone_nrs = new ArrayList<PhoneNr?> (); + + public override int n_elements { get { return this.phone_nrs.size; } } + + public PhoneNrsProperty (Persona persona) { + Object ( + property_name: "phone-numbers", + persona: persona + ); + + if (this.persona != null) { + foreach (var phone in ((PhoneDetails?) persona).phone_numbers) { + this.phone_nrs.add (new PhoneNr (phone)); + } + } + } + + 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 prop_widget) { + foreach (PhoneNr phone_nr in this.phone_nrs) + add_field (prop_widget, phone_nr); + } + + protected virtual void add_field (PropertyWidget prop_widget, PhoneNr phone_nr) { + var type_label = prop_widget.create_type_label (phone_nr.type_descr.display_name); + var value_label = prop_widget.create_value_label (phone_nr.number); + prop_widget.add_row (type_label, value_label); + } +} + +public class Contacts.EditablePhoneNrsProperty : PhoneNrsProperty, EditableProperty { + + public EditablePhoneNrsProperty (Persona? persona) { + base (persona); + + if (persona == null) { + // Fill in a dummy value + this.phone_nrs.add (new PhoneNrsProperty.PhoneNr.dummy ("Mobile")); + } + } + + protected override void add_field (PropertyWidget prop_widget, PhoneNrsProperty.PhoneNr phone_nr) { + var type_combo = prop_widget.create_type_combo (TypeSet.phone, phone_nr.type_descr); + type_combo.changed.connect ((combo) => { + phone_nr.type_descr = type_combo.active_descriptor; + }); + + var entry = prop_widget.create_value_entry (phone_nr.number); + entry.changed.connect ((editable) => { + phone_nr.number = editable.get_chars (); + }); + + var delete_button = prop_widget.create_delete_button (_("Remove phone number")); + delete_button.clicked.connect ((b) => { + phone_nr.deleted = true; + }); + + prop_widget.add_row (type_combo, entry, delete_button); + } + + public Value? create_value () { + if (this.phone_nrs.is_empty) + return null; + + var new_details = create_new_field_details (); + + // 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_phone_nrs = create_new_field_details (); + + // Check if we didn't have any changes. This is a necessary step + // XXX explain why (timeout) + var old_phone_nrs = ((PhoneDetails) this.persona).phone_numbers; + if (!check_if_equal (old_phone_nrs, new_phone_nrs)) + yield ((PhoneDetails) this.persona).change_phone_numbers (new_phone_nrs); + } + + private HashSet<PhoneFieldDetails>? create_new_field_details () { + var new_details = new HashSet<PhoneFieldDetails> (); + foreach (PhoneNrsProperty.PhoneNr phone_nr in this.phone_nrs) { + if (phone_nr.number == "" || phone_nr.deleted) + continue; + + var parameters = phone_nr.type_descr.add_type_to_parameters (phone_nr.parameters); + var phone = new PhoneFieldDetails (phone_nr.number, parameters); + new_details.add (phone); + } + + return new_details; + } +} + +public class Contacts.EmailsProperty : AggregatedPersonaProperty { + + protected class Email : Object { + public TypeDescriptor type_descr { get; set; } + public string address { get; set; default = ""; } + public MultiMap<string, string>? parameters { get; set; default = null; } + public bool deleted { get; set; default = false; } + + public Email.dummy (string type_str) { + Object (type_descr: TypeSet.email.lookup_descriptor_in_store (type_str)); + } + + public Email (EmailFieldDetails details) { + Object (type_descr: TypeSet.email.lookup_descriptor_for_field_details (details), + address: details.value, + parameters: details.parameters); + } + } + + protected Gee.List<Email> emails = new ArrayList<Email> (); + + public override int n_elements { get { return this.emails.size; } } + + public EmailsProperty (Persona? persona) { + Object ( + property_name: "email-addresses", + persona: persona + ); + + if (persona != null) { + unowned EmailDetails? details = persona as EmailDetails; + foreach (var email in details.email_addresses) + this.emails.add (new Email (email)); + } + } + + 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 prop_widget) { + foreach (var email in this.emails) + add_field (prop_widget, email); + } + + protected virtual void add_field (PropertyWidget prop_widget, Email email) { + var type_label = prop_widget.create_type_label (email.type_descr.display_name); + var url = "mailto:" + Uri.escape_string (email.address, "@", false); + var value_label = prop_widget.create_value_link (email.address, url); + prop_widget.add_row (type_label, value_label); + } +} + +public class Contacts.EditableEmailsProperty : EmailsProperty, EditableProperty { + + public EditableEmailsProperty (Persona? persona) { + base (persona); + + if (persona == null) + this.emails.add (new Email.dummy ("Personal")); + } + + protected override void add_field (PropertyWidget prop_widget, EmailsProperty.Email email) { + var type_combo = prop_widget.create_type_combo (TypeSet.email, email.type_descr); + type_combo.changed.connect ((combo) => { + email.type_descr = type_combo.active_descriptor; + }); + + var entry = prop_widget.create_value_entry (email.address); + entry.changed.connect ((editable) => { + email.address = editable.get_chars (); + }); + var delete_button = prop_widget.create_delete_button (_("Remove email address")); + + prop_widget.add_row (type_combo, entry, delete_button); + } + + public Value? create_value () { + if (this.emails.is_empty) + return null; + + var new_details = create_new_field_details (); + // 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_emails = create_new_field_details (); + var old_emails = ((EmailDetails) this.persona).email_addresses; + + if (!check_if_equal (old_emails, new_emails)) + yield ((EmailDetails) this.persona).change_email_addresses (new_emails); + } + + private HashSet<EmailFieldDetails>? create_new_field_details () { + var new_details = new HashSet<EmailFieldDetails> (); + foreach (var email in this.emails) { + if (email.address != "" || email.deleted) + continue; + + var parameters = email.type_descr.add_type_to_parameters (email.parameters); + var details = new EmailFieldDetails (email.address, parameters); + new_details.add (details); + } + + return new_details; + } +} + +public class Contacts.UrlsProperty : AggregatedPersonaProperty { + + protected class Url : Object { + public string url { get; set; default = ""; } + public MultiMap<string, string>? parameters { get; set; default = null; } + public bool deleted { get; set; default = false; } + + public Url.dummy () { + } + + public Url (UrlFieldDetails details) { + Object (url: details.value, parameters: details.parameters); + } + } + + protected Gee.List<Url> urls = new ArrayList<Url> (); + + public override int n_elements { get { return this.urls.size; } } + + public UrlsProperty (Persona? persona) { + Object ( + property_name: "urls", + persona: persona + ); + + if (persona != null) { + unowned UrlDetails? details = persona as UrlDetails; + foreach (var detail in details.urls) + this.urls.add (new Url (detail)); + } + } + + 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 prop_widget) { + foreach (var url in this.urls) + add_field (prop_widget, url); + } + + protected virtual void add_field (PropertyWidget prop_widget, Url url) { + var type_label = prop_widget.create_type_label (_("Website")); + var url_link = Uri.escape_string (url.url, "@", false); + var value_label = prop_widget.create_value_link (url.url, url_link); + + prop_widget.add_row (type_label, value_label); + } +} + +public class Contacts.EditableUrlsProperty : UrlsProperty, EditableProperty { + + public EditableUrlsProperty (Persona? persona) { + base (persona); + + if (persona == null) + this.urls.add (new Url.dummy()); + } + + protected override void add_field (PropertyWidget prop_widget, UrlsProperty.Url url) { + var type_label = prop_widget.create_type_label (_("Website")); + var entry = prop_widget.create_value_entry (url.url); + entry.changed.connect ((editable) => { + url.url = editable.get_chars (); + }); + var delete_button = prop_widget.create_delete_button (_("Remove website")); + + prop_widget.add_row (type_label, entry, delete_button); + } + + public Value? create_value () { + if (this.urls.is_empty) + return null; + + var new_details = create_new_field_details (); + // 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_new_field_details (); + yield ((UrlDetails) this.persona).change_urls (new_urls); + } + + private HashSet<UrlFieldDetails>? create_new_field_details () { + var new_details = new HashSet<UrlFieldDetails> (); + foreach (var url in this.urls) { + if (url.url == "" || url.deleted) + continue; + + var url_details = new UrlFieldDetails (url.url, url.parameters); + new_details.add (url_details); + } + + return new_details; + } +} + +public class Contacts.NotesProperty : AggregatedPersonaProperty { + + protected class Note : Object { + public string text { get; set; default = ""; } + public MultiMap<string, string>? parameters { get; set; default = null; } + public bool deleted { get; set; default = false; } + + public Note.dummy () { + } + + public Note (NoteFieldDetails details) { + Object (text: details.value, parameters: details.parameters); + } + } + + protected Gee.List<Note> notes = new ArrayList<Note> (); + + public override int n_elements { get { return this.notes.size; } } + + public NotesProperty (Persona? persona) { + Object ( + property_name: "notes", + persona: persona + ); + + if (persona != null) { + unowned NoteDetails? details = persona as NoteDetails; + foreach (var note_detail in details.notes) + this.notes.add (new Note (note_detail)); + } + } + + 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 prop_widget) { + foreach (var note in this.notes) + add_field (prop_widget, note); + } + + protected virtual void add_field (PropertyWidget prop_widget, Note note) { + var type_label = prop_widget.create_type_label (_("Note")); + var value_label = prop_widget.create_value_label (note.text); + prop_widget.add_row (type_label, value_label); + } +} + +public class Contacts.EditableNotesProperty : NotesProperty, EditableProperty { + + public EditableNotesProperty (Persona persona) { + base (persona); + + if (persona == null) + this.notes.add (new Note.dummy ()); + } + + protected override void add_field (PropertyWidget prop_widget, NotesProperty.Note note) { + var type_label = prop_widget.create_type_label (_("Note")); + var textview_container = prop_widget.create_value_textview (note.text); + /* XXX entry.changed.connect ((editable) => { */ + /* this.urls[row_nr] = editable.get_chars (); */ + /* }); */ + var delete_button = prop_widget.create_delete_button (_("Remove note")); + + prop_widget.add_row (type_label, textview_container, delete_button); + } + + public Value? create_value () { + if (this.notes.is_empty) + return null; + + var new_details = create_new_field_details (); + // 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_new_field_details (); + yield ((NoteDetails) this.persona).change_notes (new_addrs); + } + + private HashSet<NoteFieldDetails>? create_new_field_details () { + var new_details = new HashSet<NoteFieldDetails> (); + foreach (var note in this.notes) { + if (note.text == "" || note.deleted) + continue; + + var note_detail = new NoteFieldDetails (note.text, note.parameters); + new_details.add (note_detail); + } + + return new_details; + } +} + +public class Contacts.PostalAddressesProperty : AggregatedPersonaProperty { + + // Note that this is a wrapper around Folkd.PostalAddress + protected class PostalAddr : Object { + public TypeDescriptor type_descr { get; set; } + public PostalAddress address { get; set; } + public MultiMap<string, string>? parameters { get; set; default = null; } + public bool deleted { get; set; default = false; } + + public PostalAddr.dummy (string type_str) { + Object ( + type_descr: TypeSet.general.lookup_descriptor_in_store (type_str), + address: new PostalAddress("", "", "", "", "", "", "", "", "") + ); + } + + public PostalAddr (PostalAddressFieldDetails details) { + Object (type_descr: TypeSet.general.lookup_descriptor_for_field_details (details), + address: details.value, + parameters: details.parameters); + } + + public bool is_empty () { + return this.address.po_box == "" && + this.address.extension == "" && + this.address.street == "" && + this.address.locality == "" && + this.address.region == "" && + this.address.postal_code == "" && + this.address.country == "" && + this.address.address_format == ""; + } + } + + protected Gee.List<PostalAddr> addresses = new ArrayList<PostalAddr> (); + + public override int n_elements { get { return this.addresses.size; } } + + public PostalAddressesProperty (Persona? persona) { + Object ( + property_name: "postal-addresses", + persona: persona + ); + + if (persona != null) { + unowned PostalAddressDetails? details = persona as PostalAddressDetails; + foreach (var address_details in details.postal_addresses) { + this.addresses.add (new PostalAddr (address_details)); + } + } + } + + 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 prop_widget) { + foreach (var addr in this.addresses) + add_field (prop_widget, addr); + } + + protected virtual void add_field (PropertyWidget prop_widget, PostalAddr addr) { + var type_label = prop_widget.create_type_label (addr.type_descr.display_name); + var value_label = prop_widget.create_value_label (format_address (addr.address)); + prop_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.EditablePostalAddressesProperty : PostalAddressesProperty, 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 EditablePostalAddressesProperty (Persona? persona) { + base (persona); + + if (persona == null) + this.addresses.add (new PostalAddr.dummy ("Home")); + } + + protected override void add_field (PropertyWidget prop_widget, PostalAddressesProperty.PostalAddr addr) { + var type_combo = prop_widget.create_type_combo (TypeSet.general, addr.type_descr); + type_combo.changed.connect ((combo) => { + addr.type_descr = 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; + addr.address.get (address_part, out part); + + var part_entry = prop_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 = prop_widget.create_delete_button (_("Remove postal address")); + + prop_widget.add_row (type_combo, grid, delete_button); + } + + public Value? create_value () { + if (this.addresses.is_empty) + return null; + + var new_details = create_new_field_details (); + // 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_new_field_details (); + + var old_addrs = ((PostalAddressDetails) this.persona).postal_addresses; + if (!check_if_equal (old_addrs, new_addrs)) + yield ((PostalAddressDetails) this.persona).change_postal_addresses (new_addrs); + } + + private HashSet<PostalAddressFieldDetails>? create_new_field_details () { + var new_details = new HashSet<PostalAddressFieldDetails> (); + foreach (var addr in this.addresses) { + if (addr.is_empty() || addr.deleted) + continue; + + var parameters = addr.type_descr.add_type_to_parameters (addr.parameters); + var address = new PostalAddressFieldDetails (addr.address, parameters); + new_details.add (address); + } + + return new_details; + } +} diff --git a/src/contacts-type-combo.vala b/src/contacts-type-combo.vala index db998a4..a75212c 100644 --- a/src/contacts-type-combo.vala +++ b/src/contacts-type-combo.vala @@ -51,7 +51,6 @@ public class Contacts.TypeCombo : ComboBox { construct { this.valign = Align.START; this.halign = Align.FILL; - this.hexpand = true; this.visible = true; var renderer = new CellRendererText (); diff --git a/src/contacts-type-descriptor.vala b/src/contacts-type-descriptor.vala index efc96ce..a83fad5 100644 --- a/src/contacts-type-descriptor.vala +++ b/src/contacts-type-descriptor.vala @@ -89,28 +89,33 @@ 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) { + has_pref = TypeDescriptor.parameters_have_type_pref (old_parameters); + + // 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 switch (this.source) { case Source.VCARD: @@ -130,8 +135,58 @@ 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; + } + + public static bool parameters_have_type_pref (MultiMap<string, string> parameters) { + foreach (var val in parameters["type"]) + if (val.ascii_casecmp ("PREF") == 0) + return true; + + return false; + } + + /** + * Checks whether the values related to a {@link TypeDescriptor} in the given + * parameters (as one might find in a {@link Folks.AbstractFieldDetails}) are + * equal. + * + * @param parameters_a: The first parameters multimap to compare + * @param parameters_b: The second parameters multimap to compare + * + * @return: Whether the type parameters ("type" and "PREF") are equal + */ + public static bool check_type_parameters_equal (MultiMap<string, string> parameters_a, + MultiMap<string, string> parameters_b) { + // First check if some "PREF" value changed + if (TypeDescriptor.parameters_have_type_pref (parameters_a) + != TypeDescriptor.parameters_have_type_pref (parameters_b)) + return false; + + // Next, check for any custom Google property labels + var google_label_a = Utils.get_first<string> (parameters_a[X_GOOGLE_LABEL]); + var google_label_b = Utils.get_first<string> (parameters_b[X_GOOGLE_LABEL]); + if (google_label_a != null || google_label_b != null) { + // Note that we do a case-sensitive comparison for custom labels + return google_label_a == google_label_b; + } + + // Finally, check the type parameters + var types_a = new ArrayList<string>.wrap (parameters_a["type"].to_array ()); + var types_b = new ArrayList<string>.wrap (parameters_b["type"].to_array ()); + + if (types_a.size != types_b.size) + return false; + + // Now we check if types are esual. Note that we might be a bit more strict + // than truly necessary, but from a UI perspective they are still the same + types_a.sort (); + types_b.sort (); + for (int i = 0; i < types_a.size; i++) + if (types_a[i].ascii_casecmp (types_b[i]) != 0) + return false; + + return true; } /** 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..2d33881 100644 --- a/src/meson.build +++ b/src/meson.build @@ -90,6 +90,7 @@ contacts_vala_sources = files( 'contacts-linking.vala', 'contacts-list-pane.vala', 'contacts-max-width-bin.vala', + 'contacts-persona-property.vala', 'contacts-settings.vala', 'contacts-setup-window.vala', 'contacts-type-combo.vala', |