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