[california] Subscribe to Google Calendar: Closes bgo#725763



commit 5b7dbebf2aacee55f659b1d30a9c16e6551adb4e
Author: Jim Nelson <jim yorba org>
Date:   Thu Apr 3 17:27:28 2014 -0700

    Subscribe to Google Calendar: Closes bgo#725763
    
    This also takes better advantage of GtkStack to present steps to
    the user for subscribing to any calendar.

 configure.ac                                       |    4 +
 po/POTFILES.in                                     |    5 +
 src/Makefile.am                                    |   13 +
 src/activator/activator-instance-list.vala         |   64 +++--
 src/activator/activator-instance.vala              |   11 +-
 src/activator/activator-window.vala                |   18 +-
 src/activator/activator.vala                       |    9 +-
 .../activator-google-authenticating-pane.vala      |  158 +++++++++
 .../activator-google-calendar-list-pane.vala       |  188 +++++++++++
 .../google/activator-google-login-pane.vala        |   60 ++++
 src/activator/google/activator-google.vala         |   31 ++
 src/activator/webcal/activator-webcal-pane.vala    |   17 +-
 src/activator/webcal/activator-webcal.vala         |    9 +-
 src/backing/backing-caldav-subscribable.vala       |   30 ++
 src/backing/backing-webcal-subscribable.vala       |    2 +-
 src/backing/eds/backing-eds-store.vala             |   34 ++-
 src/california-resources.xml                       |    9 +
 src/rc/activator-list.ui                           |    3 +-
 src/rc/google-authenticating.ui                    |   95 ++++++
 src/rc/google-calendar-list.ui                     |  162 +++++++++
 src/rc/google-login.ui                             |  133 ++++++++
 src/rc/webcal-subscribe.ui                         |    1 +
 src/util/util-deck.vala                            |  341 ++++++++++++++++++++
 src/util/util-listbox-model.vala                   |  276 ++++++++++++++++
 24 files changed, 1618 insertions(+), 55 deletions(-)
---
diff --git a/configure.ac b/configure.ac
index b5ce289..dcdd335 100644
--- a/configure.ac
+++ b/configure.ac
@@ -29,6 +29,8 @@ GTK_REQUIRED=3.10.7
 GEE_REQUIRED=0.10.5
 ECAL_REQUIRED=3.8.5
 LIBSOUP_REQUIRED=2.44
+GDATA_REQUIRED=0.14.0
+GOA_REQUIRED=3.8.3
 
 PKG_CHECK_MODULES(CALIFORNIA, \
        glib-2.0 >= $GLIB_REQUIRED \
@@ -37,6 +39,8 @@ PKG_CHECK_MODULES(CALIFORNIA, \
        gee-0.8 >= $GEE_REQUIRED \
        libecal-1.2 >= $ECAL_REQUIRED \
        libsoup-2.4 >= $LIBSOUP_REQUIRED \
+       libgdata >= $GDATA_REQUIRED \
+       goa-1.0 >= $GOA_REQUIRED \
 )
 AC_SUBST(CALIFORNIA_CFLAGS)
 AC_SUBST(CALIFORNIA_LIBS)
diff --git a/po/POTFILES.in b/po/POTFILES.in
index 7fbe95b..933329c 100644
--- a/po/POTFILES.in
+++ b/po/POTFILES.in
@@ -1,6 +1,8 @@
 [encoding: UTF-8]
 # List of source files which contain translatable strings.
 src/activator/activator.vala
+src/activator/google/google-authenticating-pane.vala
+src/activator/google/google-calendar-list-pane.vala
 src/application/california-application.vala
 src/calendar/calendar.vala
 src/calendar/calendar-date.vala
@@ -12,5 +14,8 @@ src/host/host-show-event.vala
 [type: gettext/glade]src/rc/calendar-manager-list.ui
 [type: gettext/glade]src/rc/calendar-manager-list-item.ui
 [type: gettext/glade]src/rc/create-update-event.ui
+[type: gettext/glade]src/rc/google-authenticating.ui
+[type: gettext/glade]src/rc/google-calendar-list.ui
+[type: gettext/glade]src/rc/google-login.ui
 [type: gettext/glade]src/rc/show-event.ui
 [type: gettext/glade]src/rc/webcal-subscribe.ui
diff --git a/src/Makefile.am b/src/Makefile.am
index fad4e73..071c509 100644
--- a/src/Makefile.am
+++ b/src/Makefile.am
@@ -13,6 +13,11 @@ california_VALASOURCES = \
        activator/activator-instance-list.vala \
        activator/activator-window.vala \
        \
+       activator/google/activator-google.vala \
+       activator/google/activator-google-authenticating-pane.vala \
+       activator/google/activator-google-calendar-list-pane.vala \
+       activator/google/activator-google-login-pane.vala \
+       \
        activator/webcal/activator-webcal.vala \
        activator/webcal/activator-webcal-pane.vala \
        \
@@ -21,6 +26,7 @@ california_VALASOURCES = \
        application/main.vala \
        \
        backing/backing.vala \
+       backing/backing-caldav-subscribable.vala \
        backing/backing-calendar-source.vala \
        backing/backing-calendar-source-subscription.vala \
        backing/backing-calendar-subscription-manager.vala \
@@ -85,7 +91,9 @@ california_VALASOURCES = \
        manager/manager-calendar-list-item.vala \
        manager/manager-window.vala \
        \
+       util/util-deck.vala \
        util/util-gfx.vala \
+       util/util-listbox-model.vala \
        util/util-memory.vala \
        util/util-string.vala \
        util/util-uri.vala \
@@ -110,6 +118,9 @@ california_RC = \
        rc/calendar-manager-list.ui \
        rc/calendar-manager-list-item.ui \
        rc/create-update-event.ui \
+       rc/google-authenticating.ui \
+       rc/google-calendar-list.ui \
+       rc/google-login.ui \
        rc/show-event.ui \
        rc/webcal-subscribe.ui \
        $(NULL)
@@ -132,6 +143,8 @@ california_VALAFLAGS = \
        --pkg libecal-1.2 \
        --pkg libical \
        --pkg libsoup-2.4 \
+       --pkg libgdata \
+       --pkg=Goa-1.0 \
        $(NULL)
 
 california_CFLAGS = \
diff --git a/src/activator/activator-instance-list.vala b/src/activator/activator-instance-list.vala
index 2c94778..f7c3bcb 100644
--- a/src/activator/activator-instance-list.vala
+++ b/src/activator/activator-instance-list.vala
@@ -7,51 +7,75 @@
 namespace California.Activator {
 
 [GtkTemplate (ui = "/org/yorba/california/rc/activator-list.ui")]
-public class InstanceList : Gtk.Grid, Host.Interaction {
-    private class Item : Gtk.Label {
-        public Activator.Instance activator;
-        
-        public Item(Activator.Instance activator) {
-            this.activator = activator;
-            
-            label = activator.title;
-            xalign = 0.0f;
-            margin = 4;
-        }
-    }
+public class InstanceList : Gtk.Grid, Card {
+    public const string ID = "ActivatorInstanceList";
+    
+    public string card_id { get { return ID; } }
+    
+    public string? title { get { return null; } }
     
     public Gtk.Widget? default_widget { get { return add_button; } }
     
+    public Gtk.Widget? initial_focus { get { return listbox; } }
+    
     [GtkChild]
     private Gtk.ListBox listbox;
     
     [GtkChild]
     private Gtk.Button add_button;
     
-    public signal void selected(Activator.Instance activator);
+    private ListBoxModel<Instance> model;
     
     public InstanceList() {
-        foreach (Activator.Instance activator in activators)
-            listbox.add(new Item(activator));
+        model = new ListBoxModel<Instance>(listbox, model_presentation, activator_comparator);
+        model.add_many(activators);
+        
+        model.activated.connect(on_item_activated);
+        model.bind_property(ListBoxModel.PROP_SELECTED, add_button, "sensitive", BindingFlags.SYNC_CREATE,
+            selected_to_sensitive);
         
         show_all();
     }
     
-    [GtkCallback]
-    private void on_listbox_row_activated(Gtk.ListBoxRow? row) {
-        if (row != null)
-            selected(((Item) row.get_child()).activator);
+    private bool selected_to_sensitive(Binding binding, Value source_value, ref Value target_value) {
+        target_value = (model.selected != null);
+        
+        return true;
+    }
+    
+    public void jumped_to(Card? from, Value? message) {
+    }
+    
+    private void on_item_activated(Instance activator) {
+        start(activator);
     }
     
     [GtkCallback]
     private void on_add_button_clicked() {
-        on_listbox_row_activated(listbox.get_selected_row());
+        if (model.selected != null)
+            start(model.selected);
     }
     
     [GtkCallback]
     private void on_cancel_button_clicked() {
         dismissed(true);
     }
+    
+    private void start(Instance activator) {
+        jump_to_card_by_name(activator.first_card_id, null);
+    }
+    
+    private Gtk.Widget model_presentation(Instance activator) {
+        Gtk.Label label = new Gtk.Label(activator.title);
+        label.xalign = 0.0f;
+        label.margin = 4;
+        
+        return label;
+    }
+    
+    private int activator_comparator(Instance a, Instance b) {
+        return String.stricmp(a.title, b.title);
+    }
 }
 
 }
diff --git a/src/activator/activator-instance.vala b/src/activator/activator-instance.vala
index 01d84e0..ae9393b 100644
--- a/src/activator/activator-instance.vala
+++ b/src/activator/activator-instance.vala
@@ -33,16 +33,23 @@ public abstract class Instance : BaseObject {
      */
     public Backing.Store store { get; private set; }
     
+    /**
+     * The { link Card.card_id} of the first Card returns by { link create_cards}.
+     */
+    public abstract string first_card_id { get; }
+    
     protected Instance(string title, Backing.Store store) {
         this.title = title;
         this.store = store;
     }
     
     /**
-     * Return a { link Host.Interaction} that guides the user through the steps to create a
+     * Return a collection of { link Cards} that guides the user through the steps to create a
      * { link Backing.Source}.
+     *
+     * The first Card will be jumped to initially.
      */
-    public abstract Host.Interaction create_interaction(Soup.URI? supplied_uri);
+    public abstract Gee.List<Card> create_cards(Soup.URI? supplied_uri);
     
     public override string to_string() {
         return title;
diff --git a/src/activator/activator-window.vala b/src/activator/activator-window.vala
index 89fdea7..ed14006 100644
--- a/src/activator/activator-window.vala
+++ b/src/activator/activator-window.vala
@@ -13,19 +13,21 @@ namespace California.Activator {
 public class Window : Host.ModalWindow {
     private static Activator.Window? instance = null;
     
+    private Deck deck = new Deck();
+    
     private Window(Gtk.Window? parent) {
         base (parent);
         
-        InstanceList list = new InstanceList();
+        // The Deck is pre-populated with each of their Cards, with the InstanceList jumping to
+        // the right set when asked to (and acting as home)
+        Gee.List<Card> cards = new Gee.ArrayList<Card>();
+        cards.add(new InstanceList());
+        foreach (Instance activator in activators)
+            cards.add_all(activator.create_cards(null));
         
-        // when an Activator instance is selected from the list, swap out the list for the
-        // Activator's own interaction
-        list.selected.connect(activator => {
-            content_area.remove(list);
-            content_area.add(activator.create_interaction(null));
-        });
+        deck.add_cards(cards);
         
-        content_area.add(list);
+        content_area.add(deck);
     }
     
     public static void display(Gtk.Window? parent) {
diff --git a/src/activator/activator.vala b/src/activator/activator.vala
index 2ab4c2d..27bddf7 100644
--- a/src/activator/activator.vala
+++ b/src/activator/activator.vala
@@ -18,7 +18,7 @@ namespace California.Activator {
 
 private int init_count = 0;
 
-private Gee.TreeSet<Instance> activators;
+private Gee.List<Instance> activators;
 
 public void init() throws Error {
     if (!Unit.do_init(ref init_count))
@@ -26,13 +26,14 @@ public void init() throws Error {
     
     Backing.init();
     
-    activators = new Gee.TreeSet<Instance>(activator_comparator);
+    activators = new Gee.ArrayList<Instance>();
     
     // All Instances that work with EDS
     Backing.EdsStore? eds_store = Backing.Manager.instance.get_store_of_type<Backing.EdsStore>()
         as Backing.EdsStore;
     assert(eds_store != null);
     activators.add(new WebCalActivator(_("Web calendar (.ics)"), eds_store));
+    activators.add(new GoogleActivator(_("Google Calendar"), eds_store));
 }
 
 public void terminate() {
@@ -44,9 +45,5 @@ public void terminate() {
     Backing.terminate();
 }
 
-private int activator_comparator(Instance a, Instance b) {
-    return String.stricmp(a.title, b.title);
-}
-
 }
 
diff --git a/src/activator/google/activator-google-authenticating-pane.vala 
b/src/activator/google/activator-google-authenticating-pane.vala
new file mode 100644
index 0000000..ca9800f
--- /dev/null
+++ b/src/activator/google/activator-google-authenticating-pane.vala
@@ -0,0 +1,158 @@
+/* Copyright 2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later).  See the COPYING file in this distribution.
+ */
+
+namespace California.Activator {
+
+[GtkTemplate (ui = "/org/yorba/california/rc/google-authenticating.ui")]
+public class GoogleAuthenticatingPane : Gtk.Grid, Card {
+    public const string ID = "GoogleAuthenticatingPane";
+    
+    private const int SUCCESS_DELAY_MSEC = 1500;
+    
+    public class Message : BaseObject {
+        public string username { get; private set; }
+        public string password { get; private set; }
+        
+        public Message(string username, string password) {
+            this.username = username;
+            this.password = password;
+        }
+        
+        public override string to_string() {
+            return "Google:%s".printf(username);
+        }
+    }
+    
+    private static string? app_id = null;
+    
+    public string card_id { get { return ID; } }
+    
+    public string? title { get { return null; } }
+    
+    public Gtk.Widget? default_widget { get { return again_button; } }
+    
+    public Gtk.Widget? initial_focus { get { return null; } }
+    
+    [GtkChild]
+    private Gtk.Spinner spinner;
+    
+    [GtkChild]
+    private Gtk.Label message_label;
+    
+    [GtkChild]
+    private Gtk.Button cancel_button;
+    
+    [GtkChild]
+    private Gtk.Button again_button;
+    
+    private Cancellable cancellable = new Cancellable();
+    
+    public GoogleAuthenticatingPane() {
+        if (app_id == null)
+            app_id = "yorba-california-%s".printf(Application.VERSION);
+    }
+    
+    public void jumped_to(Card? from, Value? message) {
+        Message? credentials = message as Message;
+        assert(credentials != null);
+        
+        cancel_button.sensitive = true;
+        again_button.sensitive = false;
+        
+        cancellable = new Cancellable();
+        login_async.begin(credentials);
+    }
+    
+    [GtkCallback]
+    private void on_cancel_button_clicked() {
+        // spinner's active property doubles as flag if async operation is in progress
+        if (!spinner.active) {
+            jump_home();
+            
+            return;
+        }
+        
+        cancellable.cancel();
+        cancel_button.sensitive = false;
+    }
+    
+    [GtkCallback]
+    private void on_again_button_clicked() {
+        jump_back();
+    }
+    
+    private async void login_async(Message credentials) {
+        spinner.active = true;
+        
+        GData.ClientLoginAuthorizer authorizer = new GData.ClientLoginAuthorizer(app_id,
+            typeof(GData.CalendarService));
+        authorizer.captcha_challenge.connect(uri => { debug("CAPTCHA required: %s", uri); return ""; } );
+        
+        try {
+            if (!yield authorizer.authenticate_async(credentials.username, credentials.password, 
cancellable)) {
+                login_failed(_("Unable to authenticate with Google Calendar service"));
+                
+                return;
+            }
+        } catch (Error err) {
+            if (err is IOError.CANCELLED)
+                login_cancelled();
+            else if (err is GData.ClientLoginAuthorizerError.BAD_AUTHENTICATION)
+                login_failed(_("Unable to authenticate: Incorrect account name or password"));
+            else
+                login_failed(_("Unable to authenticate: %s").printf(err.message));
+            
+            spinner.active = false;
+            
+            return;
+        }
+        
+        GData.CalendarService calservice = new GData.CalendarService(authorizer);
+        
+        GData.Feed own_calendars;
+        GData.Feed all_calendars;
+        try {
+            own_calendars = calservice.query_own_calendars(null, cancellable, null);
+            all_calendars = calservice.query_all_calendars(null, cancellable, null);
+        } catch (Error err) {
+            if (err is IOError.CANCELLED)
+                login_cancelled();
+            else
+                login_failed(_("Unable to retrieve calendar list: %s").printf(err.message));
+            
+            spinner.active = false;
+            
+            return;
+        }
+        
+        spinner.active = false;
+        
+        message_label.label = _("Authenticated");
+        
+        // depending on network conditions, this pane can come and go quite quickly; this brief
+        // delay gives the user a chance to see what's transpired
+        Timeout.add(SUCCESS_DELAY_MSEC, () => {
+            jump_to_card_by_name(GoogleCalendarListPane.ID, new GoogleCalendarListPane.Message(
+                credentials.username, own_calendars, all_calendars));
+            
+            return false;
+        });
+    }
+    
+    private void login_failed(string msg) {
+        message_label.label = msg;
+        spinner.active = false;
+        again_button.sensitive = true;
+    }
+    
+    private void login_cancelled() {
+        spinner.active = false;
+        jump_home();
+    }
+}
+
+}
+
diff --git a/src/activator/google/activator-google-calendar-list-pane.vala 
b/src/activator/google/activator-google-calendar-list-pane.vala
new file mode 100644
index 0000000..f50afa7
--- /dev/null
+++ b/src/activator/google/activator-google-calendar-list-pane.vala
@@ -0,0 +1,188 @@
+/* Copyright 2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later).  See the COPYING file in this distribution.
+ */
+
+namespace California.Activator {
+
+[GtkTemplate (ui = "/org/yorba/california/rc/google-calendar-list.ui")]
+public class GoogleCalendarListPane : Gtk.Grid, Card {
+    public const string ID = "GoogleCalendarListPane";
+    
+    public class Message : BaseObject {
+        public string username { get; private set; }
+        public GData.Feed own_calendars { get; private set; }
+        public GData.Feed all_calendars { get; private set; }
+        
+        public Message(string username, GData.Feed own_calendars, GData.Feed all_calendars) {
+            this.username = username;
+            this.own_calendars = own_calendars;
+            this.all_calendars = all_calendars;
+        }
+        
+        public override string to_string() {
+            return "Google Calendar feeds";
+        }
+    }
+    
+    public string card_id { get { return ID; } }
+    
+    public string? title { get { return null; } }
+    
+    public Gtk.Widget? default_widget { get { return subscribe_button; } }
+    
+    public Gtk.Widget? initial_focus { get { return null; } }
+    
+    [GtkChild]
+    private Gtk.ListBox own_calendars_listbox;
+    
+    [GtkChild]
+    private Gtk.ListBox unowned_calendars_listbox;
+    
+    [GtkChild]
+    private Gtk.Button subscribe_button;
+    
+    private Backing.CalDAVSubscribable store;
+    private string? username = null;
+    private ListBoxModel<GData.CalendarCalendar> own_calendars_model;
+    private ListBoxModel<GData.CalendarCalendar> unowned_calendars_model;
+    
+    public GoogleCalendarListPane(Backing.CalDAVSubscribable store) {
+        this.store = store;
+        
+        own_calendars_listbox.set_placeholder(create_placeholder());
+        unowned_calendars_listbox.set_placeholder(create_placeholder());
+        
+        own_calendars_model = new ListBoxModel<GData.CalendarCalendar>(own_calendars_listbox,
+            entry_to_widget, entry_comparator);
+        unowned_calendars_model = new ListBoxModel<GData.CalendarCalendar>(unowned_calendars_listbox,
+            entry_to_widget, entry_comparator);
+    }
+    
+    private static Gtk.Widget create_placeholder() {
+        Gtk.Label label = new Gtk.Label(null);
+        label.set_markup("<i>" + _("None") + "</i>");
+        
+        return label;
+    }
+    
+    public void jumped_to(Card? from, Value? message) {
+        Message? feeds = message as Message;
+        assert(feeds != null);
+        
+        own_calendars_model.clear();
+        unowned_calendars_model.clear();
+        subscribe_button.sensitive = false;
+        
+        username = feeds.username;
+        
+        // add all "own" calendars, keeping track of id to not add them when traversing all calendars
+        Gee.HashSet<string> own_ids = new Gee.HashSet<string>();
+        foreach (GData.Entry entry in feeds.own_calendars.get_entries()) {
+            GData.CalendarCalendar calendar = (GData.CalendarCalendar) entry;
+            
+            own_calendars_model.add(calendar);
+            own_ids.add(calendar.id);
+        }
+        
+        // add everything not in previous list
+        foreach (GData.Entry entry in feeds.all_calendars.get_entries()) {
+            GData.CalendarCalendar calendar = (GData.CalendarCalendar) entry;
+            
+            if (!own_ids.contains(calendar.id))
+                unowned_calendars_model.add(calendar);
+        }
+    }
+    
+    [GtkCallback]
+    private void on_cancel_button_clicked() {
+        jump_home();
+    }
+    
+    [GtkCallback]
+    private void on_subscribe_button_clicked() {
+        if (own_calendars_model.selected != null)
+            subscribe_async.begin(own_calendars_model.selected);
+        else if (unowned_calendars_model.selected != null)
+            subscribe_async.begin(unowned_calendars_model.selected);
+    }
+    
+    private Gtk.Widget entry_to_widget(GData.CalendarCalendar calendar) {
+        Gtk.Label label = new Gtk.Label(calendar.title);
+        label.xalign = 0.0f;
+        
+        return label;
+    }
+    
+    private int entry_comparator(GData.CalendarCalendar a, GData.CalendarCalendar b) {
+        return String.stricmp(a.title, b.title);
+    }
+    
+    [GtkCallback]
+    private void on_listbox_selected(Gtk.ListBox listbox, Gtk.ListBoxRow? row) {
+        // make sure there's only one selection between the two listboxes
+        if (row != null) {
+            if (listbox == own_calendars_listbox)
+                unowned_calendars_listbox.select_row(null);
+            else
+                own_calendars_listbox.select_row(null);
+        }
+        
+        subscribe_button.sensitive = (row != null);
+    }
+    
+    private async void subscribe_async(GData.CalendarCalendar calendar) {
+        subscribe_button.sensitive = false;
+        
+        // convert feed URI into an iCal URI
+        Soup.URI? uri = null;
+        string? errmsg = null;
+        try {
+            uri = URI.parse(calendar.content_uri);
+            
+            // look for first path element after "/feeds/", which is the resource name of the
+            // calendar
+            string[] elements = Soup.URI.decode(uri.path).split("/");
+            string? resource_name = null;
+            for (int ctr = 0; ctr < elements.length; ctr++) {
+                if (elements[ctr] == "feeds") {
+                    if (ctr < elements.length - 1)
+                        resource_name = elements[ctr + 1];
+                    
+                    break;
+                }
+            }
+            
+            if (resource_name == null)
+                errmsg = _("Bad Google URI \"%s\"").printf(uri.to_string(false));
+            else
+                uri.set_path("/calendar/dav/%s/events".printf(Soup.URI.encode(resource_name, null)));
+        } catch (Error err) {
+            errmsg = err.message;
+        }
+        
+        if (errmsg != null) {
+            Application.instance.error_message(_("Unable to subscribe to %s: %s").printf(
+                calendar.title, errmsg));
+            
+            dismissed(false);
+            
+            return;
+        }
+        
+        debug("Subscribing to %s", uri.to_string(false));
+        try {
+            yield store.subscribe_caldav_async(calendar.title, uri, username,
+                calendar.color.to_hexadecimal(), null);
+            completed();
+        } catch (Error err) {
+            Application.instance.error_message(_("Unable to subscribe to %s: %s").printf(
+                calendar.title, err.message));
+        }
+        
+        dismissed(true);
+    }
+}
+
+}
diff --git a/src/activator/google/activator-google-login-pane.vala 
b/src/activator/google/activator-google-login-pane.vala
new file mode 100644
index 0000000..438af3e
--- /dev/null
+++ b/src/activator/google/activator-google-login-pane.vala
@@ -0,0 +1,60 @@
+/* Copyright 2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later).  See the COPYING file in this distribution.
+ */
+
+namespace California.Activator {
+
+[GtkTemplate (ui = "/org/yorba/california/rc/google-login.ui")]
+internal class GoogleLoginPane : Gtk.Grid, Card {
+    public const string ID = "GoogleLoginPane";
+    
+    public string card_id { get { return ID; } }
+    
+    public string? title { get { return null; } }
+    
+    public Gtk.Widget? default_widget { get { return login_button; } }
+    
+    public Gtk.Widget? initial_focus { get { return account_entry; } }
+    
+    [GtkChild]
+    private Gtk.Entry account_entry;
+    
+    [GtkChild]
+    private Gtk.Entry password_entry;
+    
+    [GtkChild]
+    private Gtk.Button login_button;
+    
+    public GoogleLoginPane() {
+        account_entry.bind_property("text-length", login_button, "sensitive",
+            BindingFlags.SYNC_CREATE, on_entry_changed);
+        password_entry.bind_property("text-length", login_button, "sensitive",
+            BindingFlags.SYNC_CREATE, on_entry_changed);
+    }
+    
+    public void jumped_to(Card? from, Value? msg) {
+        password_entry.text = "";
+    }
+    
+    private bool on_entry_changed(Binding binding, Value source_value, ref Value target_value) {
+        target_value = account_entry.text_length > 0 && password_entry.text_length > 0;
+        
+        return true;
+    }
+    
+    [GtkCallback]
+    private void on_cancel_button_clicked() {
+        jump_home();
+    }
+    
+    [GtkCallback]
+    private void on_login_button_clicked() {
+        jump_to_card_by_name(GoogleAuthenticatingPane.ID, new GoogleAuthenticatingPane.Message(
+            account_entry.text, password_entry.text));
+    }
+}
+
+}
+
diff --git a/src/activator/google/activator-google.vala b/src/activator/google/activator-google.vala
new file mode 100644
index 0000000..973c3aa
--- /dev/null
+++ b/src/activator/google/activator-google.vala
@@ -0,0 +1,31 @@
+/* Copyright 2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later).  See the COPYING file in this distribution.
+ */
+
+namespace California.Activator {
+
+internal class GoogleActivator : Instance {
+    public override string first_card_id { get { return GoogleLoginPane.ID; } }
+    
+    private Backing.CalDAVSubscribable caldav_store;
+    
+    public GoogleActivator(string title, Backing.CalDAVSubscribable store) {
+        base (title, store);
+        
+        caldav_store = store;
+    }
+    
+    public override Gee.List<Card> create_cards(Soup.URI? supplied_uri) {
+        Gee.List<Card> cards = new Gee.ArrayList<Card>();
+        cards.add(new GoogleLoginPane());
+        cards.add(new GoogleAuthenticatingPane());
+        cards.add(new GoogleCalendarListPane(caldav_store));
+        
+        return cards;
+    }
+}
+
+}
+
diff --git a/src/activator/webcal/activator-webcal-pane.vala b/src/activator/webcal/activator-webcal-pane.vala
index 500794c..b1cb10e 100644
--- a/src/activator/webcal/activator-webcal-pane.vala
+++ b/src/activator/webcal/activator-webcal-pane.vala
@@ -7,9 +7,17 @@
 namespace California.Activator {
 
 [GtkTemplate (ui = "/org/yorba/california/rc/webcal-subscribe.ui")]
-internal class WebCalActivatorPane : Gtk.Grid, Host.Interaction {
+internal class WebCalActivatorPane : Gtk.Grid, Card {
+    public const string ID = "WebCalActivatorPane";
+    
+    public string card_id { get { return ID; } }
+    
+    public string? title { get { return null; } }
+    
     public Gtk.Widget? default_widget { get { return subscribe_button; } }
     
+    public Gtk.Widget? initial_focus { get { return name_entry; } }
+    
     [GtkChild]
     private Gtk.ColorButton color_button;
     
@@ -38,6 +46,9 @@ internal class WebCalActivatorPane : Gtk.Grid, Host.Interaction {
             BindingFlags.SYNC_CREATE, on_entry_changed);
     }
     
+    public void jumped_to(Card? from, Value? message) {
+    }
+    
     private bool on_entry_changed(Binding binding, Value source_value, ref Value target_value) {
         target_value =
             name_entry.text_length > 0 
@@ -49,7 +60,7 @@ internal class WebCalActivatorPane : Gtk.Grid, Host.Interaction {
     
     [GtkCallback]
     private void on_cancel_button_clicked() {
-        dismissed(true);
+        jump_home();
     }
     
     [GtkCallback]
@@ -65,7 +76,7 @@ internal class WebCalActivatorPane : Gtk.Grid, Host.Interaction {
         
         try {
             yield store.subscribe_webcal_async(name_entry.text, URI.parse(url_entry.text),
-                Gfx.rgb_to_uint8_rgb_string(color), null);
+                null, Gfx.rgb_to_uint8_rgb_string(color), null);
             completed();
         } catch (Error err) {
             debug("Unable to create subscription to %s: %s", url_entry.text, err.message);
diff --git a/src/activator/webcal/activator-webcal.vala b/src/activator/webcal/activator-webcal.vala
index c232adb..9d91dda 100644
--- a/src/activator/webcal/activator-webcal.vala
+++ b/src/activator/webcal/activator-webcal.vala
@@ -7,6 +7,8 @@
 namespace California.Activator {
 
 internal class WebCalActivator : Instance {
+    public override string first_card_id { get { return WebCalActivatorPane.ID; } }
+    
     private Backing.WebCalSubscribable webcal_store;
     
     public WebCalActivator(string title, Backing.WebCalSubscribable store) {
@@ -15,8 +17,11 @@ internal class WebCalActivator : Instance {
         webcal_store = store;
     }
     
-    public override Host.Interaction create_interaction(Soup.URI? supplied_uri) {
-        return new WebCalActivatorPane(webcal_store, supplied_uri);
+    public override Gee.List<Card> create_cards(Soup.URI? supplied_uri) {
+        Gee.List<Card> cards = new Gee.ArrayList<Card>();
+        cards.add(new WebCalActivatorPane(webcal_store, supplied_uri));
+        
+        return cards;
     }
 }
 
diff --git a/src/backing/backing-caldav-subscribable.vala b/src/backing/backing-caldav-subscribable.vala
new file mode 100644
index 0000000..e876281
--- /dev/null
+++ b/src/backing/backing-caldav-subscribable.vala
@@ -0,0 +1,30 @@
+/* Copyright 2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later).  See the COPYING file in this distribution.
+ */
+
+namespace California.Backing {
+
+/**
+ * Interface allowing for a { link Store} to subscribe to CalDAV calendars.
+ *
+ * See [[http://caldav.calconnect.org/]] for more information about CalDAV.
+ */
+
+public interface CalDAVSubscribable : Store {
+    /**
+     * Subscribe to a CalDAV link, creating a new { link CalendarSource} in the process.
+     *
+     * "title" is the display name of the new subscription and should probably be supplied by the
+     * user.
+     *
+     * The CalendarSource is not returned; rather, callers should be subscribed to the
+     * { link Store.added} signal for notification.
+     */
+    public abstract async void subscribe_caldav_async(string title, Soup.URI uri, string? username,
+        string color, Cancellable? cancellable) throws Error;
+}
+
+}
+
diff --git a/src/backing/backing-webcal-subscribable.vala b/src/backing/backing-webcal-subscribable.vala
index 970d119..6b029e0 100644
--- a/src/backing/backing-webcal-subscribable.vala
+++ b/src/backing/backing-webcal-subscribable.vala
@@ -23,7 +23,7 @@ public interface WebCalSubscribable : Store {
      * The CalendarSource is not returned; rather, callers should be subscribed to the
      * { link Store.added} signal for notification.
      */
-    public abstract async void subscribe_webcal_async(string title, Soup.URI uri,
+    public abstract async void subscribe_webcal_async(string title, Soup.URI uri, string? username,
         string color, Cancellable? cancellable) throws Error;
 }
 
diff --git a/src/backing/eds/backing-eds-store.vala b/src/backing/eds/backing-eds-store.vala
index 474bfc4..2b6efaf 100644
--- a/src/backing/eds/backing-eds-store.vala
+++ b/src/backing/eds/backing-eds-store.vala
@@ -4,13 +4,15 @@
  * (version 2.1 or later).  See the COPYING file in this distribution.
  */
 
+extern void e_source_webdav_set_soup_uri(E.SourceWebdav webdav, Soup.URI uri);
+
 namespace California.Backing {
 
 /**
  * An interface to the EDS source registry.
  */
 
-internal class EdsStore : Store, WebCalSubscribable {
+internal class EdsStore : Store, WebCalSubscribable, CalDAVSubscribable {
     private E.SourceRegistry? registry = null;
     private Gee.HashMap<E.Source, Source> sources = new Gee.HashMap<E.Source, Source>();
     
@@ -43,16 +45,28 @@ internal class EdsStore : Store, WebCalSubscribable {
     
     /**
      * @inheritDoc
-     *
-     * TODO: Authentication is not properly handled.
      */
-    public async void subscribe_webcal_async(string title, Soup.URI uri, string color,
+    public async void subscribe_webcal_async(string title, Soup.URI uri, string? username, string color,
         Cancellable? cancellable) throws Error {
+        yield subscribe_eds_async(title, uri, username, color, "webcal", cancellable);
+    }
+    
+    /**
+     * @inheritDoc
+     */
+    public async void subscribe_caldav_async(string title, Soup.URI uri, string? username, string color,
+        Cancellable? cancellable) throws Error {
+        yield subscribe_eds_async(title, uri, username, color, "caldav", cancellable);
+    }
+    
+    private async void subscribe_eds_async(string title, Soup.URI uri, string? username, string color,
+        string backend_name, Cancellable? cancellable) throws Error {
         if (!is_open)
             throw new BackingError.UNAVAILABLE("EDS not open");
         
         E.Source scratch = new E.Source(null, null);
-        scratch.parent = "webcal-stub";
+        // Surprise -- Google gets special treatment
+        scratch.parent = uri.host.has_suffix("google.com") ? "google-stub" : "webcal-stub";
         scratch.enabled = true;
         scratch.display_name = title;
         
@@ -61,7 +75,7 @@ internal class EdsStore : Store, WebCalSubscribable {
             as E.SourceCalendar;
         if (calendar == null)
             throw new BackingError.UNAVAILABLE("No SourceCalendar extension for scratch source");
-        calendar.backend_name = "webcal";
+        calendar.backend_name = backend_name;
         calendar.selected = true;
         calendar.color = color;
         
@@ -70,17 +84,15 @@ internal class EdsStore : Store, WebCalSubscribable {
             as E.SourceWebdav;
         if (webdav == null)
             throw new BackingError.UNAVAILABLE("No SourceWebdav extension for scratch source");
-        webdav.resource_path = uri.path;
-        webdav.resource_query = uri.query;
+        // nice method that takes care of setting things correctly in a lot of other extensions
+        e_source_webdav_set_soup_uri(webdav, uri);
         
         // required
         E.SourceAuthentication? auth = scratch.get_extension(E.SOURCE_EXTENSION_AUTHENTICATION)
             as E.SourceAuthentication;
         if (auth == null)
             throw new BackingError.UNAVAILABLE("No SourceAuthentication extension for scratch source");
-        auth.host = uri.host;
-        auth.port = uri.port;
-        auth.method = "none";
+        auth.user = username;
         
         // optional w/ baked-in defaults
         E.SourceOffline? offline = scratch.get_extension(E.SOURCE_EXTENSION_OFFLINE)
diff --git a/src/california-resources.xml b/src/california-resources.xml
index 9635cc3..f544e1b 100644
--- a/src/california-resources.xml
+++ b/src/california-resources.xml
@@ -16,6 +16,15 @@
         <file compressed="false">rc/create-update-event.ui</file>
     </gresource>
     <gresource prefix="/org/yorba/california">
+        <file compressed="true">rc/google-authenticating.ui</file>
+    </gresource>
+    <gresource prefix="/org/yorba/california">
+        <file compressed="true">rc/google-calendar-list.ui</file>
+    </gresource>
+    <gresource prefix="/org/yorba/california">
+        <file compressed="true">rc/google-login.ui</file>
+    </gresource>
+    <gresource prefix="/org/yorba/california">
         <file compressed="false">rc/show-event.ui</file>
     </gresource>
     <gresource prefix="/org/yorba/california">
diff --git a/src/rc/activator-list.ui b/src/rc/activator-list.ui
index da0d3bc..37d5cba 100644
--- a/src/rc/activator-list.ui
+++ b/src/rc/activator-list.ui
@@ -21,7 +21,6 @@
             <property name="hexpand">True</property>
             <property name="vexpand">True</property>
             <property name="activate_on_single_click">False</property>
-            <signal name="row-activated" handler="on_listbox_row_activated" 
object="CaliforniaActivatorInstanceList" swapped="no"/>
           </object>
         </child>
       </object>
@@ -43,7 +42,7 @@
         <property name="layout_style">end</property>
         <child>
           <object class="GtkButton" id="cancel_button">
-            <property name="label" translatable="yes">_Cancel</property>
+            <property name="label" translatable="yes">_Close</property>
             <property name="visible">True</property>
             <property name="can_focus">True</property>
             <property name="receives_default">True</property>
diff --git a/src/rc/google-authenticating.ui b/src/rc/google-authenticating.ui
new file mode 100644
index 0000000..87a0cc8
--- /dev/null
+++ b/src/rc/google-authenticating.ui
@@ -0,0 +1,95 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- Generated with glade 3.16.1 -->
+<interface>
+  <requires lib="gtk+" version="3.10"/>
+  <template class="CaliforniaActivatorGoogleAuthenticatingPane" parent="GtkGrid">
+    <property name="visible">True</property>
+    <property name="can_focus">False</property>
+    <property name="row_spacing">8</property>
+    <child>
+      <object class="GtkButtonBox" id="buttonbox">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <property name="valign">end</property>
+        <property name="margin_top">8</property>
+        <property name="hexpand">True</property>
+        <property name="spacing">8</property>
+        <property name="homogeneous">True</property>
+        <property name="layout_style">end</property>
+        <child>
+          <object class="GtkButton" id="cancel_button">
+            <property name="label" translatable="yes">_Cancel</property>
+            <property name="visible">True</property>
+            <property name="can_focus">True</property>
+            <property name="receives_default">True</property>
+            <property name="use_underline">True</property>
+            <signal name="clicked" handler="on_cancel_button_clicked" 
object="CaliforniaActivatorGoogleAuthenticatingPane" swapped="no"/>
+          </object>
+          <packing>
+            <property name="expand">False</property>
+            <property name="fill">True</property>
+            <property name="position">0</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkButton" id="again_button">
+            <property name="label" translatable="yes">_Try again</property>
+            <property name="visible">True</property>
+            <property name="can_focus">True</property>
+            <property name="receives_default">True</property>
+            <property name="use_underline">True</property>
+            <signal name="clicked" handler="on_again_button_clicked" 
object="CaliforniaActivatorGoogleAuthenticatingPane" swapped="no"/>
+          </object>
+          <packing>
+            <property name="expand">False</property>
+            <property name="fill">True</property>
+            <property name="position">1</property>
+          </packing>
+        </child>
+      </object>
+      <packing>
+        <property name="left_attach">0</property>
+        <property name="top_attach">2</property>
+        <property name="width">1</property>
+        <property name="height">1</property>
+      </packing>
+    </child>
+    <child>
+      <object class="GtkLabel" id="message_label">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <property name="vexpand">True</property>
+        <property name="label" translatable="yes">Authenticating…</property>
+        <property name="wrap">True</property>
+      </object>
+      <packing>
+        <property name="left_attach">0</property>
+        <property name="top_attach">1</property>
+        <property name="width">1</property>
+        <property name="height">1</property>
+      </packing>
+    </child>
+    <child>
+      <object class="GtkAlignment" id="spinner_alignment">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <property name="hexpand">True</property>
+        <property name="vexpand">True</property>
+        <child>
+          <object class="GtkSpinner" id="spinner">
+            <property name="height_request">48</property>
+            <property name="visible">True</property>
+            <property name="can_focus">False</property>
+            <property name="valign">center</property>
+          </object>
+        </child>
+      </object>
+      <packing>
+        <property name="left_attach">0</property>
+        <property name="top_attach">0</property>
+        <property name="width">1</property>
+        <property name="height">1</property>
+      </packing>
+    </child>
+  </template>
+</interface>
diff --git a/src/rc/google-calendar-list.ui b/src/rc/google-calendar-list.ui
new file mode 100644
index 0000000..a5ca801
--- /dev/null
+++ b/src/rc/google-calendar-list.ui
@@ -0,0 +1,162 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- Generated with glade 3.16.1 -->
+<interface>
+  <requires lib="gtk+" version="3.10"/>
+  <template class="CaliforniaActivatorGoogleCalendarListPane" parent="GtkGrid">
+    <property name="visible">True</property>
+    <property name="can_focus">False</property>
+    <property name="row_spacing">8</property>
+    <child>
+      <object class="GtkLabel" id="my_calendars_label">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <property name="xalign">0</property>
+        <property name="label" translatable="yes">My calendars:</property>
+        <attributes>
+          <attribute name="weight" value="bold"/>
+        </attributes>
+      </object>
+      <packing>
+        <property name="left_attach">0</property>
+        <property name="top_attach">0</property>
+        <property name="width">1</property>
+        <property name="height">1</property>
+      </packing>
+    </child>
+    <child>
+      <object class="GtkScrolledWindow" id="own_calendars_scrolledwindow">
+        <property name="visible">True</property>
+        <property name="can_focus">True</property>
+        <property name="hexpand">True</property>
+        <property name="vexpand">True</property>
+        <property name="shadow_type">in</property>
+        <property name="min_content_height">100</property>
+        <child>
+          <object class="GtkViewport" id="own_calendars_viewport">
+            <property name="visible">True</property>
+            <property name="can_focus">False</property>
+            <child>
+              <object class="GtkListBox" id="own_calendars_listbox">
+                <property name="visible">True</property>
+                <property name="can_focus">False</property>
+                <property name="hexpand">True</property>
+                <property name="vexpand">True</property>
+                <property name="selection_mode">browse</property>
+                <property name="activate_on_single_click">False</property>
+                <signal name="row-selected" handler="on_listbox_selected" swapped="no"/>
+              </object>
+            </child>
+          </object>
+        </child>
+      </object>
+      <packing>
+        <property name="left_attach">0</property>
+        <property name="top_attach">1</property>
+        <property name="width">1</property>
+        <property name="height">1</property>
+      </packing>
+    </child>
+    <child>
+      <object class="GtkButtonBox" id="buttonbox">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <property name="valign">end</property>
+        <property name="margin_top">8</property>
+        <property name="spacing">8</property>
+        <property name="layout_style">end</property>
+        <child>
+          <object class="GtkButton" id="cancel_button">
+            <property name="label" translatable="yes">_Cancel</property>
+            <property name="visible">True</property>
+            <property name="can_focus">True</property>
+            <property name="receives_default">True</property>
+            <property name="use_underline">True</property>
+            <signal name="clicked" handler="on_cancel_button_clicked" 
object="CaliforniaActivatorGoogleCalendarListPane" swapped="no"/>
+          </object>
+          <packing>
+            <property name="expand">False</property>
+            <property name="fill">True</property>
+            <property name="position">0</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkButton" id="subscribe_button">
+            <property name="label" translatable="yes">_Subscribe</property>
+            <property name="visible">True</property>
+            <property name="can_focus">True</property>
+            <property name="can_default">True</property>
+            <property name="has_default">True</property>
+            <property name="receives_default">True</property>
+            <property name="use_underline">True</property>
+            <signal name="clicked" handler="on_subscribe_button_clicked" 
object="CaliforniaActivatorGoogleCalendarListPane" swapped="no"/>
+            <style>
+              <class name="suggested-action"/>
+            </style>
+          </object>
+          <packing>
+            <property name="expand">False</property>
+            <property name="fill">True</property>
+            <property name="position">1</property>
+          </packing>
+        </child>
+      </object>
+      <packing>
+        <property name="left_attach">0</property>
+        <property name="top_attach">4</property>
+        <property name="width">1</property>
+        <property name="height">1</property>
+      </packing>
+    </child>
+    <child>
+      <object class="GtkLabel" id="other_calendars_label">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <property name="margin_top">8</property>
+        <property name="xalign">0</property>
+        <property name="label" translatable="yes">Other available calendars:</property>
+        <attributes>
+          <attribute name="weight" value="bold"/>
+        </attributes>
+      </object>
+      <packing>
+        <property name="left_attach">0</property>
+        <property name="top_attach">2</property>
+        <property name="width">1</property>
+        <property name="height">1</property>
+      </packing>
+    </child>
+    <child>
+      <object class="GtkScrolledWindow" id="unowned_calendars_scrolledwindow">
+        <property name="visible">True</property>
+        <property name="can_focus">True</property>
+        <property name="hexpand">True</property>
+        <property name="vexpand">True</property>
+        <property name="shadow_type">in</property>
+        <property name="min_content_height">100</property>
+        <child>
+          <object class="GtkViewport" id="unowned_calendars_viewport">
+            <property name="visible">True</property>
+            <property name="can_focus">False</property>
+            <child>
+              <object class="GtkListBox" id="unowned_calendars_listbox">
+                <property name="visible">True</property>
+                <property name="can_focus">False</property>
+                <property name="hexpand">True</property>
+                <property name="vexpand">True</property>
+                <property name="selection_mode">browse</property>
+                <property name="activate_on_single_click">False</property>
+                <signal name="row-selected" handler="on_listbox_selected" swapped="no"/>
+              </object>
+            </child>
+          </object>
+        </child>
+      </object>
+      <packing>
+        <property name="left_attach">0</property>
+        <property name="top_attach">3</property>
+        <property name="width">1</property>
+        <property name="height">1</property>
+      </packing>
+    </child>
+  </template>
+</interface>
diff --git a/src/rc/google-login.ui b/src/rc/google-login.ui
new file mode 100644
index 0000000..1ba6cfb
--- /dev/null
+++ b/src/rc/google-login.ui
@@ -0,0 +1,133 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- Generated with glade 3.16.1 -->
+<interface>
+  <requires lib="gtk+" version="3.10"/>
+  <template class="CaliforniaActivatorGoogleLoginPane" parent="GtkGrid">
+    <property name="visible">True</property>
+    <property name="can_focus">False</property>
+    <property name="row_spacing">8</property>
+    <child>
+      <object class="GtkButtonBox" id="buttonbox">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <property name="valign">end</property>
+        <property name="margin_top">8</property>
+        <property name="hexpand">True</property>
+        <property name="vexpand">True</property>
+        <property name="spacing">8</property>
+        <property name="homogeneous">True</property>
+        <property name="layout_style">end</property>
+        <child>
+          <object class="GtkButton" id="cancel_button">
+            <property name="label" translatable="yes">_Cancel</property>
+            <property name="visible">True</property>
+            <property name="can_focus">True</property>
+            <property name="receives_default">True</property>
+            <property name="use_underline">True</property>
+            <signal name="clicked" handler="on_cancel_button_clicked" 
object="CaliforniaActivatorGoogleLoginPane" swapped="no"/>
+          </object>
+          <packing>
+            <property name="expand">False</property>
+            <property name="fill">True</property>
+            <property name="position">0</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkButton" id="login_button">
+            <property name="label" translatable="yes">_Login</property>
+            <property name="visible">True</property>
+            <property name="can_focus">True</property>
+            <property name="can_default">True</property>
+            <property name="has_default">True</property>
+            <property name="receives_default">True</property>
+            <property name="use_underline">True</property>
+            <property name="xalign">0.62999999523162842</property>
+            <property name="yalign">0.52999997138977051</property>
+            <signal name="clicked" handler="on_login_button_clicked" 
object="CaliforniaActivatorGoogleLoginPane" swapped="no"/>
+            <style>
+              <class name="suggested-action"/>
+            </style>
+          </object>
+          <packing>
+            <property name="expand">False</property>
+            <property name="fill">True</property>
+            <property name="position">1</property>
+          </packing>
+        </child>
+      </object>
+      <packing>
+        <property name="left_attach">0</property>
+        <property name="top_attach">2</property>
+        <property name="width">2</property>
+        <property name="height">1</property>
+      </packing>
+    </child>
+    <child>
+      <object class="GtkLabel" id="account_label">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <property name="xalign">1</property>
+        <property name="xpad">8</property>
+        <property name="label" translatable="yes">Google _account name:</property>
+        <property name="use_underline">True</property>
+        <property name="mnemonic_widget">account_entry</property>
+      </object>
+      <packing>
+        <property name="left_attach">0</property>
+        <property name="top_attach">0</property>
+        <property name="width">1</property>
+        <property name="height">1</property>
+      </packing>
+    </child>
+    <child>
+      <object class="GtkEntry" id="account_entry">
+        <property name="visible">True</property>
+        <property name="can_focus">True</property>
+        <property name="has_focus">True</property>
+        <property name="is_focus">True</property>
+        <property name="activates_default">True</property>
+        <property name="width_chars">24</property>
+        <property name="input_purpose">email</property>
+      </object>
+      <packing>
+        <property name="left_attach">1</property>
+        <property name="top_attach">0</property>
+        <property name="width">1</property>
+        <property name="height">1</property>
+      </packing>
+    </child>
+    <child>
+      <object class="GtkLabel" id="password_label">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <property name="xalign">1</property>
+        <property name="xpad">8</property>
+        <property name="label" translatable="yes">Google _password:</property>
+        <property name="use_underline">True</property>
+        <property name="mnemonic_widget">password_entry</property>
+      </object>
+      <packing>
+        <property name="left_attach">0</property>
+        <property name="top_attach">1</property>
+        <property name="width">1</property>
+        <property name="height">1</property>
+      </packing>
+    </child>
+    <child>
+      <object class="GtkEntry" id="password_entry">
+        <property name="visible">True</property>
+        <property name="can_focus">True</property>
+        <property name="visibility">False</property>
+        <property name="activates_default">True</property>
+        <property name="width_chars">24</property>
+        <property name="input_purpose">password</property>
+      </object>
+      <packing>
+        <property name="left_attach">1</property>
+        <property name="top_attach">1</property>
+        <property name="width">1</property>
+        <property name="height">1</property>
+      </packing>
+    </child>
+  </template>
+</interface>
diff --git a/src/rc/webcal-subscribe.ui b/src/rc/webcal-subscribe.ui
index a20ddd1..df8749a 100644
--- a/src/rc/webcal-subscribe.ui
+++ b/src/rc/webcal-subscribe.ui
@@ -117,6 +117,7 @@
         <property name="can_focus">False</property>
         <property name="valign">end</property>
         <property name="margin_top">8</property>
+        <property name="vexpand">True</property>
         <property name="spacing">8</property>
         <property name="homogeneous">True</property>
         <property name="baseline_position">bottom</property>
diff --git a/src/util/util-deck.vala b/src/util/util-deck.vala
new file mode 100644
index 0000000..c564152
--- /dev/null
+++ b/src/util/util-deck.vala
@@ -0,0 +1,341 @@
+/* Copyright 2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later).  See the COPYING file in this distribution.
+ */
+
+namespace California {
+
+/**
+ * A Card is a single pane of widget(s) in a { link Deck}.
+ *
+ * The navigation of Cards is tracked within their Deck, and Cards can request navigation via their
+ * various signals.  They're also notified when nevigation which affects them is made.
+ */
+
+public interface Card : Gtk.Widget {
+    /**
+     * Each { link Card} has its own identifier that should be unique within the { link Deck}.
+     *
+     * In the Gtk.Stack, this is its name.
+     */
+    public abstract string card_id { get; }
+    
+    /**
+     * A user-visible string that may be used elsewhere in the application.
+     *
+     * Gtk.StackSwitcher uses this title.  { link Deck} does not use the title in any way.
+     */
+    public abstract string? title { get; }
+    
+    /** 
+     * The widget the { link Card} wants to be default when navigated to.
+     *
+     * The widget must have can-default set to true.
+     */
+    public abstract Gtk.Widget? default_widget { get; }
+    
+    /**
+     * The widget the { link Card} wants to have initial focus when navigated to.
+     *
+     * Focus is set after { link default_widget} is handled, so if this widget has receives-default
+     * set to true, it will get the default as well.
+     *
+     * The widget must have can-focus set to true.
+     */
+    public abstract Gtk.Widget? initial_focus { get; }
+    
+    /**
+     * Returns the { link Deck} this { link Card} is registered to, if any.
+     */
+    public Deck? deck { get { return parent as Deck; } }
+    
+    /**
+     * Fired when the { link Card} wishes to jump to another Card in the same { link Deck.}
+     *
+     * Each Card can accept a message which parameterizes its activation.  It's up to Cards
+     * navigating to the new one to construct and pass an appropriate message.
+     *
+     * @see jump_to_card_by_name
+     */
+    public signal void jump_to_card(Card next, Value? message);
+    
+    /**
+     * Fired when the { link Card} wishes to jump to another Card by its name.
+     *
+     * @see jump_to_card
+     */
+    public signal void jump_to_card_by_name(string name, Value? message);
+    
+    /**
+     * Fired when the { link Card} wishes to jump to the previous Card in the { link Deck}.
+     *
+     * Note that this Card's position in the navigation stack is lost; there is no "jump forward".
+     */
+    public signal void jump_back();
+    
+    /**
+     * Fired when the { link Card} wishes to jump to the first Card in the { link Deck}.
+     *
+     * This clears the Deck's navigation stack, meaning { link jump_back} will not return to
+     * this Card.
+     */
+    public signal void jump_home();
+    
+    /**
+     * Fired when the { link Deck}'s work is cancelled, closed, or dismissed, whether due to
+     * programmatic reasons or by user request.
+     *
+     * Implementing classes should fire this after firing the { link completed signal} so
+     * subscribers can maintain their cleanup in a single handler.
+     */
+    public signal void dismissed(bool user_request);
+    
+    /**
+     * Fired when the { link Deck}'s work has completed successfully.
+     *
+     * This should only be fired if the Deck requires valid input from the user to perform
+     * some intensive operation.  Merely displaying information and closing the Deck
+     * should simply fire { link dismissed}.
+     *
+     * "completed" implies that dismissed will be called shortly thereafter, meaning all
+     * cleanup can be handled there.
+     */
+    public signal void completed();
+    
+    /**
+     * Called by { link Deck} when the { link Card} has been activated, i.e. put to the "top" of
+     * the Deck.
+     *
+     * message may be null even if the Card expects one; generally this means { link jump_back}
+     * or { link jump_home} was invoked, resulting in this Card being activated.
+     *
+     * This is called before dealing with { link default_widget} and { link initial_focus}, so
+     * changes to those properties in this call, if need be.
+     */
+    public abstract void jumped_to(Card? from, Value? message);
+}
+
+/**
+ * A Deck is a collection of { link Card}s maintained within a Gtk.Stack.
+ *
+ * Cards control navigation through their various signals, which Deck monitors and acts upon.
+ * It also notifies Cards of nagivation changes which affect them via their abstract methods.
+ */
+
+public class Deck : Gtk.Stack, Host.Interaction {
+    /**
+     * @inheritedDoc
+     */
+    public Gtk.Widget? default_widget { get { return null; } }
+    
+    /**
+     * The number of { link Card}s registered to the { link Deck}.
+     */
+    public int size { get { return list.size; } }
+    
+    /**
+     * All registered { link Card}s returned as a read-only List.
+     */
+    public Gee.List<Card> cards { owned get { return list.read_only_view; } }
+    
+    /**
+     * The home { link Card}.
+     */
+    public Card? home { owned get { return (list.size > 0) ? list[0] : null; } }
+    
+    /**
+     * The current displayed { link Card}.
+     */
+    public Card? top { get; private set; default = null; }
+    
+    private Gee.List<Card> list = new Gee.LinkedList<Card>();
+    private Gee.Deque<Card> navigation_stack = new Gee.LinkedList<Card>();
+    private Gee.HashMap<string, Card> names = new Gee.HashMap<string, Card>();
+    
+    /**
+     * Create a new { link Deck}.
+     *
+     * By default the Deck configures the underlying Gtk.Stack to slide left and right, depending
+     * on the position of the { link Card}s.  This can be changed, but the recommended
+     * transition types are SLIDE_LEFT_RIGHT and SLIDE_UP_DOWN.
+     */
+    public Deck() {
+        transition_type = Gtk.StackTransitionType.SLIDE_LEFT_RIGHT;
+        notify["visible-child"].connect(on_child_to_top);
+    }
+    
+    ~Deck() {
+        foreach (Card card in names.values)
+            card.map.disconnect(on_card_mapped);
+    }
+    
+    private void on_child_to_top() {
+        // disconnect from previous top card and push onto nav stack
+        if (top != null) {
+            top.jump_to_card.disconnect(on_jump_to_card);
+            top.jump_to_card_by_name.disconnect(on_jump_to_card_by_name);
+            top.jump_back.disconnect(on_jump_back);
+            top.jump_home.disconnect(on_jump_home);
+            top.dismissed.disconnect(on_dismissed);
+            top.completed.disconnect(on_completed);
+            
+            navigation_stack.offer_head(top);
+            top = null;
+        }
+        
+        // make new visible child top Card and connect to its signals
+        top = visible_child as Card;
+        if (top != null) {
+            top.jump_to_card.connect(on_jump_to_card);
+            top.jump_to_card_by_name.connect(on_jump_to_card_by_name);
+            top.jump_back.connect(on_jump_back);
+            top.jump_home.connect(on_jump_home);
+            top.dismissed.connect(on_dismissed);
+            top.completed.connect(on_completed);
+        }
+    }
+    
+    /**
+     * Add { link Card}s to the { link Deck}.
+     *
+     * Cards can be added in multiple batches, but the ordering is important as it dictates how
+     * they're presented to the user via transitions and slides.
+     *
+     * The first Card added is the "home" Card.  The Deck will automatically show it first.
+     */
+    public void add_cards(Gee.List<Card> cards) {
+        if (cards.size == 0)
+            return;
+        
+        // if empty, first card is home and should be made visible when added
+        bool set_home_visible = size == 0;
+        
+        // add each Card using the title if possible, otherwise by ID
+        foreach (Card card in cards) {
+            // each card must have a unique name
+            assert(!String.is_empty(card.card_id));
+            assert(!names.has_key(card.card_id));
+            
+            if (String.is_empty(card.title))
+                add_named(card, card.card_id);
+            else
+                add_titled(card, card.card_id, card.title);
+            
+            names.set(card.card_id, card);
+            
+            // deal with initial_focus and default_widget when mapped, as the calls aren't
+            // guaranteed to work during programmatic navigation (especially for the first card,
+            // i.e. home)
+            card.map.connect(on_card_mapped);
+            
+            // add in order to ensure order is preserved if sparsely removed later
+            list.add(card);
+        }
+        
+        if (set_home_visible && home != null) {
+            set_visible_child(home);
+            home.jumped_to(null, null);
+        }
+    }
+    
+    /**
+     * Removes { link Card}s from the { link Deck}.
+     *
+     * If the { link top} card is removed, the Deck will return { link home}, clearing the
+     * navigation stack in the process.
+     */
+    public void remove_cards(Gee.Iterable<Card> cards) {
+        bool displaying = top != null;
+        
+        foreach (Card card in cards) {
+            if (!names.has_key(card.card_id)) {
+                message("Card %s not found in Deck", card.card_id);
+                
+                continue;
+            }
+            
+            card.map.disconnect(on_card_mapped);
+            
+            remove(card);
+            
+            if (top == card)
+                top = null;
+            
+            navigation_stack.remove(card);
+            names.unset(card.card_id);
+            list.remove(card);
+        }
+        
+        // if was displaying a Card and now not, jump home
+        if (displaying && top == null && home != null) {
+            navigation_stack.clear();
+            set_visible_child(home);
+            home.jumped_to(null, null);
+        }
+    }
+    
+    private void on_jump_to_card(Card card, Card next, Value? message) {
+        // do nothing if already visible
+        if (get_visible_child() == next) {
+            debug("Already showing card %s", next.card_id);
+            
+            return;
+        }
+        
+        // do nothing if not registered with this Deck
+        if (!names.values.contains(next)) {
+            GLib.message("Card %s not registered with Deck", next.card_id);
+            
+            return;
+        }
+        
+        set_visible_child(next);
+        next.jumped_to(card, message);
+    }
+    
+    private void on_jump_to_card_by_name(Card card, string name, Value? message) {
+        Card? next = names.get(name);
+        if (next != null)
+            on_jump_to_card(card, next, message);
+        else
+            GLib.message("Card %s not found in Deck", name);
+    }
+    
+    private void on_jump_back(Card card) {
+        // if still not empty, next card is "back", so pop that off and jump to it
+        if (!navigation_stack.is_empty)
+            on_jump_to_card(card, navigation_stack.poll_head(), null);
+    }
+    
+    private void on_jump_home(Card card) {
+        // jumping home clears the navigation stack
+        navigation_stack.clear();
+        
+        if (home != null)
+            on_jump_to_card(card, home, null);
+        else
+            message("No home card in Deck");
+    }
+    
+    private void on_dismissed(bool user_request) {
+        dismissed(user_request);
+    }
+    
+    private void on_completed() {
+        completed();
+    }
+    
+    private void on_card_mapped(Gtk.Widget widget) {
+        Card card = (Card) widget;
+        
+        if (card.default_widget != null && card.default_widget.can_default)
+            card.default_widget.grab_default();
+        
+        if (card.initial_focus != null && card.initial_focus.can_focus)
+            card.initial_focus.grab_focus();
+    }
+}
+
+}
+
diff --git a/src/util/util-listbox-model.vala b/src/util/util-listbox-model.vala
new file mode 100644
index 0000000..fb8f0d5
--- /dev/null
+++ b/src/util/util-listbox-model.vala
@@ -0,0 +1,276 @@
+/* Copyright 2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later).  See the COPYING file in this distribution.
+ */
+
+namespace California {
+
+/**
+ * A { link Mutable} is an Object which can internally change state (i.e. is no immutable).
+ *
+ * { link ListBoxModel} recognizes when an Object supports this interface and will monitor its
+ * { link mutated} signal.
+ */
+
+public interface Mutable : Object {
+    /**
+     * Fired when important internal state has changed.
+     *
+     * This can be used by collections and other containers to update their own state, such as
+     * re-sorting or re-applying filters.
+     */
+    public signal void mutated();
+}
+
+/**
+ * A simple model for Gtk.ListBox.
+ *
+ * ListBoxModel is designed to make it easier to maintain a sorted list of objects and make sure
+ * the associated Gtk.ListBox is always up-to-date reflecting the state of the model.
+ *
+ * If the added objects implement the { link Mutable} interface, their { link Mutable.mutated}
+ * signsl is monitored.  When fired, the listbox's sort and filters will be invalidated.
+ */
+
+public class ListBoxModel<G> : BaseObject {
+    public const string PROP_SELECTED = "selected";
+    
+    private const string KEY = "org.yorba.california.listbox-model.model";
+    
+    /**
+     * Returns a Gtk.Widget that is held by the Gtk.ListBox representing the particular item.
+     */
+    public delegate Gtk.Widget ModelPresentation<G>(G item);
+    
+    public Gtk.ListBox listbox { get; private set; }
+    
+    /**
+     * The number if items in the { link ListBoxModel}.
+     */
+    public int size { get { return items.size; } }
+    
+    /**
+     * The item currently selected by the { link listbox}, null if no selection has been made.
+     */
+    public G? selected { get; private set; default = null; }
+    
+    private unowned ModelPresentation model_presentation;
+    private unowned CompareDataFunc<G>? comparator;
+    private Gee.HashMap<G, Gtk.ListBoxRow> items;
+    
+    /**
+     * Fired when an item is added to the { link ListBoxModel}.
+     *
+     * @see add
+     */
+    public signal void added(G item);
+    
+    /**
+     * Fired when an item is removed from the { link ListBoxModel}.
+     *
+     * @see remove
+     */
+    public signal void removed(G item);
+    
+    /**
+     * Fired when the { link listbox} activates an item.
+     *
+     * Gtk.ListBox can activate an item with a double- or single-click, depending on configuration.
+     */
+    public signal void activated(G item);
+    
+    /**
+     * Create a { link ListBoxModel} and tie it to a Gtk.ListBox.
+     *
+     * The list will be sorted if a comparator is supplied, otherwise added items are appended to
+     * the list.
+     */
+    public ListBoxModel(Gtk.ListBox listbox, ModelPresentation<G> model_presentation,
+        CompareDataFunc<G>? comparator = null, owned Gee.HashDataFunc<G>? hash_func = null,
+        owned Gee.EqualDataFunc<G>? equal_func = null) {
+        this.listbox = listbox;
+        this.model_presentation = model_presentation;
+        this.comparator = comparator;
+        
+        items = new Gee.HashMap<G, Gtk.ListBoxRow>((owned) hash_func, (owned) equal_func);
+        
+        listbox.remove.connect(on_listbox_removed);
+        listbox.set_sort_func(listbox_sort_func);
+        listbox.row_activated.connect(on_row_activated);
+        listbox.row_selected.connect(on_row_selected);
+    }
+    
+    ~ListBoxModel() {
+        listbox.row_activated.disconnect(on_row_activated);
+        listbox.row_selected.disconnect(on_row_selected);
+        
+        foreach (G item in items.keys) {
+            Mutable? mutable = item as Mutable;
+            if (mutable != null)
+                mutable.mutated.disconnect(on_mutated);
+        }
+    }
+    
+    /**
+     * Add an item to the model, which in turns adds it to the { link listbox}.
+     *
+     * If the item implements the { link Mutable} interface, its { link Mutable.mutated} signal
+     * is monitored and will invalidate the listbox's sort and filters.
+     *
+     * Returns true if the model (and therefore the listbox) were altered due to the addition.
+     *
+     * @see added
+     */
+    public bool add(G item) {
+        if (items.has_key(item))
+            return false;
+        
+        Mutable? mutable = item as Mutable;
+        if (mutable != null)
+            mutable.mutated.connect(on_mutated);
+        
+        // item -> Gtk.ListBoxRow
+        Gtk.ListBoxRow row = new Gtk.ListBoxRow();
+        row.add(model_presentation(item));
+        
+        // mappings
+        row.set_data<G>(KEY, item);
+        items.set(item, row);
+        
+        listbox.add(row);
+        row.show_all();
+        
+        added(item);
+        
+        return true;
+    }
+    
+    /**
+     * Add a collection of { link Card}s to the { link Deck}.
+     *
+     * Returns the number of Cards added.
+     *
+     * @see add
+     */
+    public int add_many(Gee.Iterable<G> items) {
+        int count = 0;
+        foreach (G item in items) {
+            if (add(item))
+                count++;
+        }
+        
+        return count;
+    }
+    
+    /**
+     * Removes an item from the model, which in turn removes it from the { link listbox}.
+     *
+     * Returns true if the model (and therefore the listbox) were altered due to the removal.
+     *
+     * @see removed
+     */
+    public bool remove(G item) {
+        return internal_remove(item, true);
+    }
+    
+    /**
+     * Removes a collection of { link Card}s from the { link Deck}.
+     *
+     * Returns the number of Cards removed.
+     *
+     * @see remove
+     */
+    public int remove_many(Gee.Iterable<G> items) {
+        int count = 0;
+        foreach (G item in items) {
+            if (remove(item))
+                count++;
+        }
+        
+        return count;
+    }
+    
+    private bool internal_remove(G item, bool remove_from_listbox) {
+        Gtk.ListBoxRow row;
+        if (!items.unset(item, out row))
+            return false;
+        
+        Mutable? mutable = item as Mutable;
+        if (mutable != null)
+            mutable.mutated.disconnect(on_mutated);
+        
+        if (remove_from_listbox)
+            listbox.remove(row);
+        
+        removed(item);
+        
+        return true;
+    }
+    
+    /**
+     * Returns true if the model holds the item.
+     */
+    public bool contains(G item) {
+        return items.has_key(item);
+    }
+    
+    /**
+     * Clears all items from the { link ListBoxModel}.
+     *
+     * Each removed item generates a { link removed} signal.
+     */
+    public void clear() {
+        foreach (G item in items.keys)
+            remove(item);
+    }
+    
+    // This can be called by our add() method or externally, so don't be too absolutist here
+    private void on_listbox_removed(Gtk.Widget widget) {
+        // get the actual widget, not the wrapping object
+        Gtk.ListBoxRow? row = widget as Gtk.ListBoxRow;
+        if (row == null) {
+            message("GtkListBox removed non-GtkListBoxRow child");
+            
+            return;
+        }
+        
+        internal_remove(row.get_data<G>(KEY), false);
+    }
+    
+    private int listbox_sort_func(Gtk.ListBoxRow a, Gtk.ListBoxRow b) {
+        unowned G item_a = a.get_data<G>(KEY);
+        unowned G item_b = b.get_data<G>(KEY);
+        
+        if (comparator != null)
+            return comparator(item_a, item_b);
+        
+        return Gee.Functions.get_compare_func_for(typeof(G))(item_a, item_b);
+    }
+    
+    private void on_row_activated(Gtk.ListBoxRow row) {
+        activated(row.get_data<G>(KEY));
+    }
+    
+    private void on_row_selected(Gtk.ListBoxRow? row) {
+        selected = (row != null) ? row.get_data<G>(KEY) : null;
+    }
+    
+    private void on_mutated(Mutable mutable) {
+        Gtk.ListBoxRow? row = items.get((G) mutable);
+        if (row == null) {
+            message("Mutable not found in ListBoxRow");
+            
+            return;
+        }
+        
+        row.changed();
+    }
+    
+    public override string to_string() {
+        return "ListboxModel";
+    }
+}
+
+}
+


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