contrib/benitlux: intro Benitlux client prototype App
authorAlexis Laferrière <alexis.laf@xymus.net>
Sat, 12 Mar 2016 18:48:36 +0000 (13:48 -0500)
committerAlexis Laferrière <alexis.laf@xymus.net>
Thu, 19 May 2016 14:16:37 +0000 (10:16 -0400)
Signed-off-by: Alexis Laferrière <alexis.laf@xymus.net>

contrib/benitlux/Makefile
contrib/benitlux/src/client/base.nit [new file with mode: 0644]
contrib/benitlux/src/client/client.nit [new file with mode: 0644]
contrib/benitlux/src/client/features/checkins.nit [new file with mode: 0644]
contrib/benitlux/src/client/features/debug.nit [new file with mode: 0644]
contrib/benitlux/src/client/features/push.nit [new file with mode: 0644]
contrib/benitlux/src/client/features/translations.nit [new file with mode: 0644]
contrib/benitlux/src/client/views/beer_views.nit [new file with mode: 0644]
contrib/benitlux/src/client/views/home_views.nit [new file with mode: 0644]
contrib/benitlux/src/client/views/social_views.nit [new file with mode: 0644]
contrib/benitlux/src/client/views/user_views.nit [new file with mode: 0644]

index 994369b..12c65b3 100644 (file)
@@ -1,6 +1,6 @@
 SERVER ?= localhost:8080
 
-all: server
+all: server bin/report bin/benitlux
 
 server: bin/benitlux_daily bin/benitlux_web
 bin/benitlux_daily: $(shell ../../bin/nitls -M src/server/benitlux_daily.nit)
@@ -23,3 +23,10 @@ bin/report: $(shell ../../bin/nitls -M src/report.nit)
 
 report: bin/report
        bin/report
+
+# ---
+# GTK+ client
+
+bin/benitlux: $(shell ../../bin/nitls -M src/client/client.nit)
+       mkdir -p bin/
+       ../../bin/nitc -o bin/benitlux src/client/client.nit -m linux -D benitlux_rest_server_uri=http://$(SERVER)/
diff --git a/contrib/benitlux/src/client/base.nit b/contrib/benitlux/src/client/base.nit
new file mode 100644 (file)
index 0000000..bcc1efd
--- /dev/null
@@ -0,0 +1,155 @@
+# This file is part of NIT ( http://www.nitlanguage.org ).
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Common services for the Benitlux app
+module base
+
+import app::ui
+import app::data_store
+import app::http_request
+import android::aware
+import json::serialization
+
+import benitlux_model
+import translations
+
+# Show debug output?
+fun debug: Bool do return true
+
+# Root URI of the remote RESTfule server
+fun benitlux_rest_server_uri: String do return "http://localhost:8080/"
+
+redef class App
+
+       # Current connection token, or "none"
+       var token: String is lazy, writable do
+               var token = app.data_store["token"]
+               if token isa String then return token
+               return "none"
+       end
+
+       # Name of the currently logged in user
+       var user: nullable String is lazy, writable do
+               var user = app.data_store["user"]
+               if user isa nullable String then return user
+               return null
+       end
+
+       # Event when user logs in or out
+       fun on_log_in do on_save_state
+
+       redef fun on_save_state
+       do
+               app.data_store["user"] = user
+               app.data_store["token"] = token
+               super
+       end
+
+       # Has this app state been restored yet?
+       var restored = false
+
+       redef fun on_restore_state
+       do
+               super
+
+               # TODO this may happen before the lazy loading above
+               restored = true
+
+               if token != "none" then on_log_in
+       end
+
+       # Show simple feedback to the user on important errors
+       fun feedback(text: Text) do print_error text
+end
+
+# Show a notification to the user
+fun notify(title, content: Text, uniqueness_id: Int)
+do print "Notification {uniqueness_id}: {title}; {content}"
+
+# View for an item in a list, like a beer or a person
+abstract class ItemView
+       super View
+end
+
+# Basic async HTTP request for this app
+#
+# Note that connection errors are passed to `on_fail`, and
+# server errors or authentification errors are received by `on_load`
+# and should be passed to `intercept_error`.
+class BenitluxHttpRequest
+       super AsyncHttpRequest
+
+       redef fun rest_server_uri do return benitlux_rest_server_uri
+
+       redef var rest_action
+
+       redef fun on_fail(error)
+       do
+               if error isa IOError then
+                       # This should be a normal network error like being offline.
+                       # Print to log, but don't show to the user.
+                       print_error error.class_name
+               else
+                       # This could be a deserialization error,
+                       # it may be related to an outdated client.
+                       # Report to user.
+                       print_error "Request Error: {rest_server_uri / rest_action} with {error}"
+                       app.feedback "Request Error: {error}"
+               end
+       end
+
+       # Intercept known server side errors
+       fun intercept_error(res: nullable Object): Bool
+       do
+               if res isa BenitluxTokenError then
+                       app.token = "none"
+                       app.user = null
+                       return true
+               else if res isa BenitluxError then
+                       app.feedback((res.user_message or else res.message).t)
+                       return true
+               else if res isa Error then
+                       app.feedback res.message.t
+                       return true
+               end
+               return false
+       end
+end
+
+# Async request with services to act on the windows of the app
+class WindowHttpRequest
+       super BenitluxHttpRequest
+
+       autoinit window, rest_action
+
+       # Type of the related `window`
+       type W: Window
+
+       # `Window` on which to apply the results of this request
+       var window: W
+
+       # `Views` to disable while this request is in progress
+       var affected_views = new Array[View]
+
+       redef fun before do for view in affected_views do view.enabled = false
+
+       redef fun after do for view in affected_views do view.enabled = true
+end
+
+redef class Text
+       # Ellipsize `self` so it fits within `max_length` characters
+       #
+       # FIXME Remove this when labels are correctly ellipsized on iOS.
+       fun ellipsize: Text do return self
+end
diff --git a/contrib/benitlux/src/client/client.nit b/contrib/benitlux/src/client/client.nit
new file mode 100644 (file)
index 0000000..e2fc2a9
--- /dev/null
@@ -0,0 +1,44 @@
+# This file is part of NIT ( http://www.nitlanguage.org ).
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Portable Benitlux app
+module client is
+       app_name "Benitlux"
+       app_version(0, 3, git_revision)
+       app_namespace "net.xymus.benitlux"
+end
+
+import home_views
+import beer_views
+import social_views
+import user_views
+
+# ---
+# Services
+
+redef class Deserializer
+       redef fun deserialize_class(name)
+       do
+               if name == "Array[Beer]" then return new Array[Beer].from_deserializer(self)
+               if name == "Array[User]" then return new Array[User].from_deserializer(self)
+               if name == "Array[BeerBadge]" then return new Array[BeerBadge].from_deserializer(self)
+               if name == "Array[BeerAndRatings]" then return new Array[BeerAndRatings].from_deserializer(self)
+               if name == "Array[String]" then return new Array[String].from_deserializer(self)
+               if name == "Array[UserAndFollowing]" then return new Array[UserAndFollowing].from_deserializer(self)
+               return super
+       end
+end
+
+set_fr
+super
diff --git a/contrib/benitlux/src/client/features/checkins.nit b/contrib/benitlux/src/client/features/checkins.nit
new file mode 100644 (file)
index 0000000..3be087e
--- /dev/null
@@ -0,0 +1,179 @@
+# This file is part of NIT ( http://www.nitlanguage.org ).
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# On location checkin services
+module checkins
+
+import client
+
+redef class App
+
+       # Should we share our checkins with the server and friends?
+       fun share_checkins: Bool
+       do return app.data_store["share_checkins"].as(nullable Bool) or else true
+
+       # Should we share our checkins with the server and friends?
+       fun share_checkins=(value: Bool)
+       do
+               # Notify server
+               if currently_on_location then
+                       if value then
+                               server_check_in
+                       else server_check_out
+               end
+
+               app.data_store["share_checkins"] = value
+       end
+
+       # Are we currently at the location?
+       fun currently_on_location: Bool
+       do return app.data_store["currently_on_location"].as(nullable Bool) or else false
+
+       # Are we currently at the location?
+       fun currently_on_location=(value: Bool) do app.data_store["currently_on_location"] = value
+
+       # Request beer menu from the server
+       #
+       # It includes a diff if `checkins` remembers a previous visit.
+       fun request_menu
+       do
+               var checkins = checkins
+               var since = checkins.latest
+               if since != null then
+                       var today = today
+                       if since == today then
+                               since = checkins.previous
+                       end
+               end
+
+               (new MenuHttpRequest("rest/since?token={token}&date={since or else ""}")).start
+       end
+
+       # User checks in
+       fun on_check_in
+       do
+               if currently_on_location then return
+
+               if share_checkins then server_check_in
+
+               currently_on_location = true
+               request_menu
+               checkins.update today
+       end
+
+       # User checks out
+       fun on_check_out
+       do
+               if not currently_on_location then return
+
+               if share_checkins then server_check_out
+               currently_on_location = false
+       end
+
+       # Notify server of checkin
+       private fun server_check_in do (new BenitluxHttpRequest("rest/checkin?token={app.token}&is_in=true")).start
+
+       # Notify server of checkout
+       private fun server_check_out do (new BenitluxHttpRequest("rest/checkin?token={app.token}&is_in=false")).start
+
+       # History of the last 1 or 2 checkins
+       var checkins = new SimpleMemory
+
+       redef fun on_save_state
+       do
+               super
+               app.data_store["checkins"] = checkins
+       end
+
+       redef fun on_restore_state
+       do
+               var checkins = app.data_store["checkins"]
+               if checkins isa SimpleMemory then self.checkins = checkins
+
+               super
+       end
+end
+
+# Request the menu from the server for a notification
+class MenuHttpRequest
+       super BenitluxHttpRequest
+
+       redef fun on_load(data)
+       do
+               if not data isa Array[BeerAndRatings] then
+                       on_fail new Error("Server sent unexpected data {data or else "null"}")
+                       return
+               end
+
+               var content = data.beers_to_notification
+
+               notify("Passing by the Benelux?".t, content, 2)
+       end
+end
+
+# ---
+# Support services
+
+# Memory of an element and the previous one, avoiding duplication
+#
+# Used to remember the last day at the location,
+# ignoring multiple reports on the same day.
+class SimpleMemory
+       serialize
+
+       # Before latest remembered entry
+       var previous: nullable String = null
+
+       # Last remembered entry
+       var latest: nullable String = null
+
+       # Update `latest` if `value` is different
+       fun update(value: String)
+       do
+               if value == latest then return
+
+               previous = latest
+               latest = value
+       end
+end
+
+# ---
+# UI
+
+redef class UserWindow
+
+       private var lbl_checkins_options_title = new Label(parent=layout,
+               text="Share options".t)
+
+       private var chk_share_checkins = new CheckBox(parent=layout,
+               text="Share checkins with your friends".t)
+
+       init
+       do
+               chk_share_checkins.is_checked = app.share_checkins
+               lbl_checkins_options_title.size = 1.5
+       end
+
+       redef fun on_event(event)
+       do
+               super
+
+               if event isa ToggleEvent then
+                       var sender = event.sender
+                       if sender == chk_share_checkins then
+                               app.share_checkins = sender.is_checked
+                       end
+               end
+       end
+end
diff --git a/contrib/benitlux/src/client/features/debug.nit b/contrib/benitlux/src/client/features/debug.nit
new file mode 100644 (file)
index 0000000..ae7be3c
--- /dev/null
@@ -0,0 +1,67 @@
+# This file is part of NIT ( http://www.nitlanguage.org ).
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Debugging features accessible from the user preference menu
+module debug
+
+import client
+import push
+import checkins
+
+redef class UserWindow
+
+       private var layout_debug = new VerticalLayout(parent=layout)
+
+       private var lbl_debug_title = new Label(parent=layout_debug,
+               text="Debug options".t)
+
+       private var but_test_notif = new Button(parent=layout_debug,
+               text="Test notifications".t)
+
+       private var but_test_checkin = new Button(parent=layout_debug,
+               text="Test checkin".t)
+
+       private var but_test_checkout = new Button(parent=layout_debug,
+               text="Test checkout".t)
+
+       private var but_test_menu = new Button(parent=layout_debug,
+               text="Test menu diff".t)
+
+       init
+       do
+               lbl_debug_title.size = 1.5
+
+               for c in [but_test_notif, but_test_checkin, but_test_checkout, but_test_menu] do
+                       c.observers.add self
+               end
+       end
+
+       redef fun on_event(event)
+       do
+               super
+
+               if event isa ButtonPressEvent then
+                       var sender = event.sender
+                       if sender == but_test_notif then
+                               notify("Test Notification", "Some content\nmultiline", 5)
+                       else if sender == but_test_checkin then
+                               app.on_check_in
+                       else if sender == but_test_checkout then
+                               app.on_check_out
+                       else if sender == but_test_menu then
+                               app.request_menu
+                       end
+               end
+       end
+end
diff --git a/contrib/benitlux/src/client/features/push.nit b/contrib/benitlux/src/client/features/push.nit
new file mode 100644 (file)
index 0000000..3006616
--- /dev/null
@@ -0,0 +1,222 @@
+# This file is part of NIT ( http://www.nitlanguage.org ).
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Push notification support
+module push
+
+import app::http_request
+
+import client
+
+redef class App
+       redef fun on_log_in
+       do
+               super
+               #(new PushHttpRequest("push/check_token?token={app.token}")).start
+       end
+
+       # Names of the known users currently on location
+       var users_on_location = new Set[String]
+
+       # Should we show a daily notification when new beers are available?
+       fun notify_on_new_beers: Bool
+       do return app.data_store["notify_on_new_beers"].as(nullable Bool) or else true
+
+       # Should we show a daily notification when new beers are available?
+       fun notify_on_new_beers=(value: Bool) do app.data_store["notify_on_new_beers"] = value
+
+       # Should we show a daily notification of the menu?
+       fun notify_menu_daily: Bool
+       do return app.data_store["notify_menu_daily"].as(nullable Bool) or else false
+
+       # Should we show a daily notification of the menu?
+       fun notify_menu_daily=(value: Bool) do app.data_store["notify_menu_daily"] = value
+
+       # Should we show a notification when friends check in at the location?
+       fun notify_on_checkins: Bool
+       do return app.data_store["notify_on_checkins"].as(nullable Bool) or else true
+
+       # Should we show a notification when friends check in at the location?
+       fun notify_on_checkins=(value: Bool) do app.data_store["notify_on_checkins"] = value
+end
+
+# Open push notification request
+class PushHttpRequest
+       super BenitluxHttpRequest
+
+       redef fun on_fail(error)
+       do
+               if app.user == null then return
+
+               super
+
+               print_error "{class_name}: on_fail {error}"
+
+               var t = new PushHttpRequest("push/?token={app.token}")
+               t.delay = 10.0
+               t.start
+       end
+
+       redef fun on_load(data)
+       do
+               if app.user == null then return
+
+               var delay = 0.0
+               if data isa Pushable then
+                       data.apply_push_if_desired
+               else if data isa BenitluxError then
+                       # TODO if forbidden ask for a new token
+                       delay = 5.0*60.0
+               else
+                       print_error "{class_name}: Received {data or else "null"}"
+               end
+
+               var t = new PushHttpRequest("push/?token={app.token}")
+               t.delay = delay
+               t.start
+       end
+end
+
+# ---
+# Objects sent from the server to the client
+
+# Objects sent as push notifications by the server
+interface Pushable
+       # Act on this push notification
+       fun apply_push do print_error "Unimplemented `apply_push` on {class_name}"
+
+       # Consider to `apply_push` if the user preferences wants to
+       fun apply_push_if_desired do apply_push
+end
+
+redef class CheckinReport
+       super Pushable
+
+       # Flattened array of the name of users
+       var user_names: Array[String] = [for u in users do u.name] is lazy
+
+       redef fun apply_push_if_desired
+       do
+               if not app.notify_on_checkins then return
+
+               var there_is_a_new_user = false
+               for new_users in user_names do
+                       if not app.users_on_location.has(new_users) then
+                               there_is_a_new_user = true
+                               break
+                       end
+               end
+
+               app.users_on_location.clear
+               app.users_on_location.add_all user_names
+
+               # Apply only if there is someone new on location
+               if there_is_a_new_user then super
+       end
+
+       redef fun apply_push
+       do
+               if users.is_empty then
+                       #app.notif_push.cancel
+                       #self.cancel(tag, (int)id);
+                       return
+               end
+
+               var title = "TTB!".t
+               var names = [for user in users do user.name]
+               var content = "From %0".t.format(names.join(", "))
+
+               notify(title, content, 1)
+       end
+end
+
+redef class DailyNotification
+       super Pushable
+
+       redef fun apply_push_if_desired
+       do
+               if app.notify_menu_daily then
+                       super
+                       return
+               end
+
+               if app.notify_on_new_beers then
+                       for beer in beers do
+                               if beer.is_new then
+                                       super
+                                       return
+                               end
+                       end
+               end
+       end
+
+       redef fun apply_push
+       do
+               var title = if beers.has_new_beers then
+                       "New beers are on the menu".t
+               else "Beer Menu".t
+
+               var content = beers.beers_to_notification
+               notify(title, content, 3)
+       end
+end
+
+# ---
+# UI
+
+redef class UserWindow
+
+       private var layout_push_options = new VerticalLayout(parent=layout)
+
+       private var lbl_push_options_title = new Label(parent=layout_push_options,
+               text="Notifications options".t)
+
+       private var chk_notify_on_new_beers = new CheckBox(parent=layout_push_options,
+               text="Notify when there are new beers".t)
+
+       private var chk_notify_menu_daily = new CheckBox(parent=layout_push_options,
+               #text="Show the menu every work day?".t)
+               text="Show the menu every work day".t)
+
+       private var chk_notify_on_checkins = new CheckBox(parent=layout_push_options,
+               text="Notify when a friend checks in".t)
+
+       init
+       do
+               lbl_push_options_title.size = 1.5
+               chk_notify_on_new_beers.is_checked = app.notify_on_new_beers
+               chk_notify_menu_daily.is_checked = app.notify_menu_daily
+               chk_notify_on_checkins.is_checked = app.notify_on_checkins
+
+               for c in [chk_notify_menu_daily, chk_notify_on_new_beers, chk_notify_on_checkins] do
+                       c.observers.add self
+               end
+       end
+
+       redef fun on_event(event)
+       do
+               super
+
+               if event isa ToggleEvent then
+                       var sender = event.sender
+                       if sender == chk_notify_on_new_beers then
+                               app.notify_on_new_beers = sender.is_checked
+                       else if sender == chk_notify_menu_daily then
+                               app.notify_menu_daily = sender.is_checked
+                       else if sender == chk_notify_on_checkins then
+                               app.notify_on_checkins = sender.is_checked
+                       end
+               end
+       end
+end
diff --git a/contrib/benitlux/src/client/features/translations.nit b/contrib/benitlux/src/client/features/translations.nit
new file mode 100644 (file)
index 0000000..02deb61
--- /dev/null
@@ -0,0 +1,103 @@
+# This file is part of NIT ( http://www.nitlanguage.org ).
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+
+# Support for translating the app to different languages, implements French
+module translations
+
+redef class Text
+       # Translate `self` according to the current language `sys.lang`
+       fun t: String
+       do
+               var lang = sys.lang_map
+               if lang == null then return to_s
+
+               if lang.keys.has(self) then return lang[self]
+
+               print "Translation miss ({sys.lang}): {self}"
+               return to_s
+       end
+end
+
+redef class Sys
+       # Name of the language in use
+       var lang = "C"
+
+       # Translation map for the language in use
+       var lang_map: nullable Map[Text, String] = null
+end
+
+# Set French as the current language
+fun set_fr
+do
+       var map = new Map[Text, String]
+
+       # Home views
+       map["Welcome %0"] = "Bienvenue %0"
+       map["Welcome"] = "Bienvenue"
+       map["Beer Menu"] = "Menu de bières"
+       map["View all"] = "Menu complet"
+       map["Preferences"] = "Préférences"
+       map["Friends"] = "Amis"
+       map["Manage"] = "Gérer"
+       map["Events"] = "Événements"
+       map["Loading..."] = "Chargement..."
+       map["Login or signup"] = "S'authentifier"
+       map["On location?"] = "Sur place?"
+       map["Leaving?"] = "Vous quittez?"
+
+       # User/login views
+       map["Account options"] = "Options du compte"
+       map["Share options"] = "Options de partage"
+       map["Notifications options"] = "Options de notification"
+       map["Please login"] = "Veuillez vous identifier"
+       map["Welcome %0!"] = "Bienvenue %0!"
+       map["Logged in as %0"] = "Connecté en tant que %0"
+       map["Username"] = "Nom d'utilisateur"
+       map["Invalid name"] = "Nom d'utilisateur invalide"
+       map["Password"] = "Mot de passe"
+       map["Passwords must be composed of at least 6 characters."] = "Le mot de passe doit avoir au moins 6 charactères."
+       map["Email"] = "Courriel"
+       map["Login"] = "Se connecter"
+       map["Logout"] = "Se déconnecter"
+       map["Signup"] = "Créer un compte"
+
+       # Social views
+       map["Follow"] = "Suivre"
+       map["Unfollow"] = "Ne plus suivre"
+       map["Search"] = "Rechercher"
+       map["Favorites: %0"] = "Favoris: %0"
+       map["No favorites yet"] = "Pas de favoris"
+       map["List followed"] = "Personnes suivies"
+       map["List followers"] = "Personnes vous suivant"
+
+       # Beer views
+       map["Review %0"] = "Évaluer %0"
+       map["%0★ %1 reviews"] = "%0★ %1 avis"
+       map["No reviews yet"] = "Aucun avis"
+       map[", friends: %0☆ %1 reviews"] = ", amis: %0☆ %1 avis"
+       map[" (New)"] = " (Nouveau)"
+       map["Similar to %0."] = "Similaire à %0."
+       map["Favorite beer on the menu."] = "Bière préférée sur le menu."
+       map["Favorite of %0"] = "Préférée de %0"
+
+       # Preferences
+       map["Notify when a friend checks in"] = "Lorsqu'un ami est sur place"
+       map["Show the menu every work day"] = "Menu journalier en semaine"
+       map["Notify when there are new beers"] = "Lorsqu'il y a de nouvelles bières"
+       map["Share checkins with your friends"] = "Partager lorsque vous êtes sur place"
+       map["Passing by the Benelux?"] = "De passage au Bénélux?"
+
+       # Update Sys
+       sys.lang = "fr"
+       sys.lang_map = map
+end
diff --git a/contrib/benitlux/src/client/views/beer_views.nit b/contrib/benitlux/src/client/views/beer_views.nit
new file mode 100644 (file)
index 0000000..54ad9e9
--- /dev/null
@@ -0,0 +1,329 @@
+# This file is part of NIT ( http://www.nitlanguage.org ).
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Window to list beers and other beer-related views
+module beer_views
+
+import base
+
+# View about a beer, with its name, description and rating
+class BeerView
+       super VerticalLayout
+       super ItemView
+
+       autoinit beer_info, parent
+
+       # Beer information
+       var beer_info: BeerAndRatings
+
+       # Buttons to realize the rating buttons
+       var star_buttons = new Array[StarButton]
+
+       # Layout of the first line with the name and `star_buttons`
+       var top_line_layout = new HorizontalLayout(parent=self)
+
+       init
+       do
+               var lbl_name = new Label(parent=top_line_layout, text=beer_info.beer.name)
+               lbl_name.size = 1.25
+
+               var desc = beer_info.beer.desc
+               if beer_info.is_new then desc += " (New)".t
+               var lbl_desc = new Label(parent=self, text=desc)
+
+               var lbl_stats = new Label(parent=self, text=beer_info.rating_text)
+               lbl_stats.size = 0.5
+
+               var badges = beer_info.badges
+               if badges != null then
+                       var lbl_comment = new Label(parent=self, text=badges.join(" "))
+                       lbl_comment.size = 0.5
+               end
+
+               var rating = beer_info.user_rating or else 0
+               setup_stars rating
+       end
+
+       # Prepare and display the rating controls
+       fun setup_stars(rating: Int)
+       do
+               var l_stars = new HorizontalLayout(parent=top_line_layout)
+
+               for i in [1..5] do
+                       var but = new StarButton(beer_info.beer, i, i <= rating, parent=l_stars)
+                       but.size = 1.5
+                       but.observers.add self
+                       star_buttons.add but
+               end
+       end
+
+       redef fun on_event(event)
+       do
+               assert event isa ButtonPressEvent
+
+               var sender = event.sender
+               if sender isa StarButton then
+                       on_review sender.rating
+               end
+       end
+
+       # Post a user review
+       fun on_review(rating: Int)
+       do
+               var beer_id = beer_info.beer.id
+               (new ReviewAction(app.window, "rest/review?token={app.token}&beer={beer_id}&rating={rating}")).start
+
+               # Update UI
+               var i = 1
+               for but in star_buttons do
+                       but.on = i <= rating
+                       i += 1
+               end
+       end
+end
+
+# Beers pane listing the available beers
+class BeersWindow
+       super Window
+
+       private var layout = new VerticalLayout(parent=self)
+       private var list_beers = new ListLayout(parent=layout)
+
+       init
+       do
+               if debug then print "BenitluxWindow::init"
+
+               action_list_beers
+       end
+
+       # Send HTTP request to list beers
+       fun action_list_beers
+       do (new ListBeersAction(self, "rest/list?token={app.token}")).start
+end
+
+# ---
+# Customized buttons
+
+# View to describe and rate a eer
+class RatingView
+       super View
+
+       autoinit beer, init_rating, parent, enabled
+
+       # Beer id
+       var beer: Beer
+
+       # Previous rating, 0 for none
+       var init_rating: Int
+
+       redef fun parent=(layout) do end
+
+       redef fun enabled=(value) do end
+end
+
+# Button with a star, filled or not, for rating beers
+class StarButton
+       super Button
+
+       autoinit beer, rating, on, parent, enabled
+
+       # Info on the beer to rate
+       var beer: Beer
+
+       # Rating of `beer`
+       var rating: Int
+
+       # Set if the star is filled
+       fun on=(on: Bool) is autoinit do text = if on then "★" else "☆"
+end
+
+redef class BeerAndRatings
+       # Text version of the ratings
+       fun rating_text: String
+       do
+               var txt = new Array[String]
+
+               var global = global
+               if global != null and global.count > 0 then
+                       txt.add "%0★ %1 reviews".t.format(global.average.to_precision(1), global.count)
+               else txt.add "No reviews yet".t
+
+               var local = followed
+               if local != null and local.count > 0 then
+                       txt.add ", friends: %0☆ %1 reviews".t.format(local.average.to_precision(1), local.count)
+               end
+
+               return txt.join
+       end
+end
+
+redef class Beer
+       # Capitalize first letter for a prettier display
+       redef fun desc
+       do
+               var desc = super
+               if desc.length == 0 then return desc
+
+               var first_letter = desc.first.to_upper
+               return first_letter.to_s + desc.substring_from(1)
+       end
+end
+
+# Comparator of beers
+class BeerComparator
+       super Comparator
+
+       redef type COMPARED: BeerAndRatings
+
+       redef fun compare(a, b) do return value_of(a) <=> value_of(b)
+
+       private fun value_of(beer: COMPARED): Float
+       do
+               var max = 0.0
+               var value = 0.0
+
+               var rating = beer.user_rating
+               if rating != null then
+                       max += 20.0
+                       value += rating.to_f * 4.0
+               end
+
+               var followed = beer.followed
+               if followed != null then
+                       max += 10.0
+                       value += followed.average * 2.0
+               end
+
+               var global = beer.global
+               if global != null then
+                       max += 5.0
+                       value += global.average
+               end
+
+               return (max - value)/max
+       end
+end
+
+# Async request to submit a review
+class ReviewAction
+       super WindowHttpRequest
+
+       redef fun on_load(res)
+       do
+               if intercept_error(res) then return
+       end
+end
+
+# Async request to update the beer list
+class ListBeersAction
+       super WindowHttpRequest
+
+       redef type W: BeersWindow
+
+       redef fun on_load(beers)
+       do
+               window.layout.remove window.list_beers
+               window.list_beers = new ListLayout(parent=window.layout)
+
+               if intercept_error(beers) then return
+
+               if not beers isa Array[BeerAndRatings] then
+                       app.feedback "Communication Error".t
+                       return
+               end
+
+               # Sort beers per preference
+               var comparator = new BeerComparator
+               comparator.sort beers
+
+               # Populate the list
+               for beer_and_rating in beers do
+                       var view = new BeerView(beer_and_rating, parent=window.list_beers)
+               end
+       end
+end
+
+redef class BestBeerBadge
+       redef fun to_s do return "Favorite beer on the menu.".t
+end
+
+redef class FavoriteBeerBadge
+       redef fun to_s do return "Favorite of %0.".t.format(users.join(", ", " & "))
+end
+
+redef class SimilarBeerBadge
+       redef fun to_s do return "Similar to %0.".t.format(beers.join(", ", " & "))
+end
+
+redef class Array[E]
+       # Pretty compressed list of this list of beer as a pseudo diff
+       #
+       # Require: `self isa Array[BeerAndRatings]`
+       fun beers_to_notification: String
+       do
+               assert self isa Array[BeerAndRatings]
+
+               # Sort beers per preference
+               var comparator = new BeerComparator
+               comparator.sort self
+
+               # Organize the notification line per line
+               # First the new beers, then the fixed one.
+               var lines = new Array[String]
+               var fix_beers = new Array[String]
+               for bar in self do
+                       var beer = bar.beer
+                       if bar.is_new then
+                               lines.add "+ {beer.name}: {beer.desc}"
+                       else fix_beers.add beer.name
+               end
+
+               # Show a few fixed beers per line
+               if fix_beers.not_empty then
+                       var line = new FlatBuffer
+                       line.append "= "
+                       for i in fix_beers.length.times, beer in fix_beers do
+
+                               if i > 0 then line.append ", "
+
+                               var l = line.length + beer.length
+                               if l < 42 then # Very approximate width of a notification on Android
+                                       line.append beer
+                                       continue
+                               end
+
+                               lines.add line.to_s
+
+                               line = new FlatBuffer
+                               line.append "= "
+                               line.append beer
+                       end
+
+                       lines.add line.to_s
+               end
+
+               return lines.join("\n")
+       end
+
+       # Does `self` has a new beer?
+       #
+       # Require: `self isa Array[BeerAndRatings]`
+       fun has_new_beers: Bool
+       do
+               assert self isa Array[BeerAndRatings]
+
+               for beer in self do if beer.is_new then return true
+               return false
+       end
+end
diff --git a/contrib/benitlux/src/client/views/home_views.nit b/contrib/benitlux/src/client/views/home_views.nit
new file mode 100644 (file)
index 0000000..c991f3c
--- /dev/null
@@ -0,0 +1,211 @@
+# This file is part of NIT ( http://www.nitlanguage.org ).
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Main home window
+module home_views
+
+import beer_views
+import social_views
+import user_views
+
+redef class App
+       redef fun on_create
+       do
+               if debug then print "App::on_create"
+
+               # Create the main window
+               show_home
+               super
+       end
+
+       # Show the home/main windows
+       fun show_home
+       do
+               var window = new HomeWindow
+               window.refresh
+               push_window window
+       end
+
+       redef fun on_log_in
+       do
+               super
+
+               # Send back to the home window when logging in
+               if not window isa HomeWindow then pop_window
+       end
+end
+
+# Social pane with networking features
+class HomeWindow
+       super Window
+
+       private var layout = new ListLayout(parent=self)
+
+       # Cut-point for the iOS adaptation
+       var layout_user = new VerticalLayout(parent=layout)
+       private var layout_login = new HorizontalLayout(parent=layout_user)
+       private var lbl_login_status = new Label(parent=layout_login, text="Welcome".t, size=1.5)
+       private var but_login = new Button(parent=layout_login, text="Login or signup".t)
+       private var but_preferences = new Button(parent=layout_login, text="Preferences".t)
+
+       private var layout_beers = new VerticalLayout(parent=layout)
+       var layout_beers_title = new HorizontalLayout(parent=layout_beers)
+       var title_beers = new SectionTitle(parent=layout_beers_title, text="Beer Menu".t, size=1.5)
+       private var beer_button = new Button(parent=layout_beers_title, text="View all".t)
+       private var beer_list = new VerticalLayout(parent=layout_beers)
+       private var beer_temp_lbl = new Label(parent=beer_list, text="Loading...".t)
+
+       private var layout_social = new VerticalLayout(parent=layout)
+       private var social_header = new HorizontalLayout(parent=layout_social)
+       private var social_title = new SectionTitle(parent=social_header, text="Friends".t, size=1.5)
+       private var social_button = new Button(parent=social_header, text="Manage".t)
+       private var social_list = new VerticalLayout(parent=layout_social)
+       private var social_temp_lbl = new Label(parent=social_list, text="Loading...".t)
+
+       private var layout_news = new VerticalLayout(parent=layout)
+       private var news_header = new HorizontalLayout(parent=layout_news)
+       private var news_title = new SectionTitle(parent=news_header, text="Events".t, size=1.5)
+       #private var news_button = new Button(parent=news_header, text="Open website") # TODO
+       private var news_label = new Label(parent=layout_news, text="Bière en cask le jeudi!")
+
+       init
+       do
+               for c in [but_login, but_preferences, beer_button, social_button] do
+                       c.observers.add self
+               end
+       end
+
+       redef fun on_resume do refresh
+
+       # Refresh content of this page
+       fun refresh
+       do
+               if not app.restored then return
+
+               layout_login.clear
+               if app.user != null then
+                       # Logged in
+                       lbl_login_status.parent = layout_login
+                       but_preferences.parent = layout_login
+                       lbl_login_status.set_welcome
+               else
+                       but_login.parent = layout_login
+                       but_preferences.parent = layout_login
+               end
+
+               # Fill beers
+               (new ListDiffAction(self, "rest/since?token={app.token}")).start
+
+               # Fill people
+               (new HomeListPeopleAction(self, "rest/friends?token={app.token}")).start
+
+               # Check if token is still valid
+               (new CheckTokenAction(self, "rest/check_token?token={app.token}")).start
+       end
+
+       redef fun on_event(event)
+       do
+               if debug then print "BenitluxWindow::on_event {event}"
+
+               if event isa ButtonPressEvent then
+                       var sender = event.sender
+                       if sender == but_preferences then
+                               app.push_window new UserWindow
+                               return
+                       else if sender == but_login then
+                               app.push_window new SignupWindow
+                               return
+                       else if sender == beer_button then
+                               app.push_window new BeersWindow
+                               return
+                       else if sender == social_button then
+                               app.push_window new SocialWindow
+                               return
+                       #else if sender == news_button then
+                               # TODO open browser?
+                       end
+               end
+
+               super
+       end
+end
+
+# `Label` used in section headers
+class SectionTitle super Label end
+
+# Async request to update the beer list on the home screen
+class ListDiffAction
+       super WindowHttpRequest
+
+       redef type W: HomeWindow
+
+       redef fun on_load(beers)
+       do
+               window.layout_beers.remove window.beer_list
+               window.beer_list = new VerticalLayout(parent=window.layout_beers)
+
+               if intercept_error(beers) then return
+
+               if not beers isa Array[BeerAndRatings] then
+                       app.feedback "Communication Error".t
+                       return
+               end
+
+               # Sort beers per preference
+               var comparator = new BeerComparator
+               comparator.sort beers
+
+               var max_beers = 6
+               while beers.length > max_beers do beers.pop
+
+               for bar in beers do
+                       var view = new BeerView(bar, parent=window.beer_list)
+               end
+       end
+end
+
+# Async request to list users
+class HomeListPeopleAction
+       super WindowHttpRequest
+
+       redef type W: HomeWindow
+
+       redef fun on_load(users)
+       do
+               window.layout_social.remove window.social_list
+               window.social_list = new VerticalLayout(parent=window.layout_social)
+
+               if intercept_error(users) then return
+
+               if users isa Array[UserAndFollowing] then for uaf in users do
+                       var view = new PeopleView(uaf, true, parent=window.social_list)
+               end
+       end
+end
+
+# Async request to check if `app.token` is still valid
+class CheckTokenAction
+       super WindowHttpRequest
+
+       redef type W: HomeWindow
+
+       redef fun on_load(res) do intercept_error(res)
+end
+
+# Today's date as a `String`
+fun today: String
+do
+       var tm = new Tm.localtime
+       return "{tm.year+1900}-{tm.mon+1}-{tm.mday}"
+end
diff --git a/contrib/benitlux/src/client/views/social_views.nit b/contrib/benitlux/src/client/views/social_views.nit
new file mode 100644 (file)
index 0000000..35cb6bd
--- /dev/null
@@ -0,0 +1,201 @@
+# This file is part of NIT ( http://www.nitlanguage.org ).
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Window to list beers and other beer-related views
+module social_views
+
+import base
+
+# Social pane with networking features
+class SocialWindow
+       super Window
+
+       private var layout = new VerticalLayout(parent=self)
+
+       private var list_search = new ListLayout(parent=layout)
+
+       private var layout_header = new VerticalLayout(parent=list_search)
+       private var layout_search = new HorizontalLayout(parent=layout_header)
+       private var txt_query = new TextInput(parent=layout_search)
+       private var but_search = new Button(parent=layout_search, text="Search".t)
+
+       private var layout_list = new HorizontalLayout(parent=layout_header)
+       private var but_followed = new Button(parent=layout_list, text="List followed".t)
+       private var but_followers = new Button(parent=layout_list, text="List followers".t)
+
+       init
+       do
+               for c in [but_search, but_followed, but_followers] do
+                       c.observers.add self
+               end
+
+               # Load friends and suggestions
+               (new ListUsersAction(self, "rest/friends?token={app.token}&n=16")).start
+       end
+
+       redef fun on_event(event)
+       do
+               if debug then print "BenitluxWindow::on_event {event}"
+
+               if event isa ButtonPressEvent then
+                       var sender = event.sender
+                       if sender == but_search then
+                               search
+                       else if sender == but_followed then
+                               var cmd = "rest/followed?token={app.token}"
+                               (new ListUsersAction(self, cmd)).start
+                       else if sender == but_followers then
+                               var cmd = "rest/followers?token={app.token}"
+                               (new ListUsersAction(self, cmd)).start
+                       end
+               end
+
+               super
+       end
+
+       # Execute search with `txt_query.text`
+       fun search
+       do
+               var query = txt_query.text
+               if query == null or query.is_empty then return
+
+               var res = "rest/search?token={app.token}&query={query}&offset=0"
+               (new ListUsersAction(self, res)).start
+       end
+
+       # Fill `list_search` with views for each of `users`
+       fun list_users(users: Array[UserAndFollowing])
+       do
+               for uaf in users do
+                       var view = new PeopleView(uaf, false, parent=list_search)
+               end
+       end
+end
+
+# View to describe, and follow a person
+class PeopleView
+       super VerticalLayout
+       super ItemView
+
+       autoinit user_and_following, home_window_mode, parent
+
+       # Description of the user
+       var user_and_following: UserAndFollowing
+
+       # Toggle tweaks for the home window where the is no "unfollow" buttons
+       var home_window_mode: Bool
+
+       init
+       do
+               var user = user_and_following.user
+
+               var layout_top_line = new HorizontalLayout(parent=self)
+               var lbl_name = new Label(parent=layout_top_line, text=user.name)
+
+               if app.user != null then
+
+                       # Show unfollow button if not on the home screen
+                       if not home_window_mode or not user_and_following.following then
+                               var but = new FollowButton(user.id, user_and_following.following, user_and_following.followed, parent=layout_top_line)
+                               but.observers.add self
+                       end
+               end
+
+               var favs = if not user_and_following.favs.is_empty then
+                       "Favorites: %0".t.format(user_and_following.favs)
+               else "No favorites yet".t
+               var lbl_desc = new Label(parent=self, text=favs, size=0.5)
+       end
+end
+
+# Button to follow or unfollow a user
+class FollowButton
+       super Button
+
+       autoinit followed_id, following, followed_by, parent, enabled, text
+
+       # Id of the user to be followd/unfollow
+       var followed_id: Int
+
+       # Does the local user already follows `followed_id`
+       var following: Bool
+
+       # Does `followed_id` already follows the local user
+       var followed_by: Bool
+
+       # Update the visible text according to `following`
+       fun update_text do text = if following then "Unfollow".t else "Follow".t
+
+       init do update_text
+
+       redef fun on_event(event)
+       do
+               assert event isa ButtonPressEvent
+               var cmd = "rest/follow?token={app.token}&user_to={followed_id}&follow={not following}"
+               enabled = false
+               text = "Updating...".t
+               (new FollowAction(app.window, cmd, self)).start
+       end
+end
+
+# Async request to receive and display a list of users
+#
+# This is used by many features of the social window:
+# search, list followed and list followers.
+class ListUsersAction
+       super WindowHttpRequest
+
+       redef type W: SocialWindow
+
+       init do affected_views.add_all([window.but_search, window.but_followed, window.but_followers])
+
+       redef fun on_load(users)
+       do
+               window.layout.remove window.list_search
+               window.list_search = new ListLayout(parent=window.layout)
+               window.layout_header.parent = window.list_search
+
+               if intercept_error(users) then return
+
+               if users isa Array[UserAndFollowing] then window.list_users users
+       end
+end
+
+# Async request to follow or unfollow a user
+class FollowAction
+       super WindowHttpRequest
+
+       private var button: FollowButton
+       init do affected_views.add(button)
+
+       redef fun on_load(res)
+       do
+               if intercept_error(res) then return
+       end
+
+       redef fun after
+       do
+               button.following = not button.following
+               button.update_text
+               button.enabled = true
+
+               super
+       end
+
+       redef fun before
+       do
+               button.enabled = false
+               super
+       end
+end
diff --git a/contrib/benitlux/src/client/views/user_views.nit b/contrib/benitlux/src/client/views/user_views.nit
new file mode 100644 (file)
index 0000000..e45fcb5
--- /dev/null
@@ -0,0 +1,215 @@
+# This file is part of NIT ( http://www.nitlanguage.org ).
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# User preference window and other user-related view
+module user_views
+
+import base
+
+redef class Label
+       # Update the content of `lbl_welcome`
+       fun set_user_name
+       do
+               var name = app.user
+               self.text = if name != null then
+                       "Logged in as %0".t.format(name)
+               else "Not logged in".t
+       end
+
+       # Set `text` to welcome an authentified user or invite to authentify
+       fun set_welcome
+       do
+               var name = app.user
+               self.text = if name != null then
+                       "Welcome %0".t.format(name)
+               else ""
+       end
+end
+
+# User preference window
+class UserWindow
+       super Window
+
+       # Main window layout
+       var layout = new ListLayout(parent=self)
+
+       private var layout_user_options = new VerticalLayout(parent=layout)
+
+       private var lbl_user_options_title = new Label(parent=layout_user_options,
+               text="Account options".t)
+
+       private var lbl_welcome = new Label(parent=layout_user_options)
+       private var but_logout = new Button(parent=layout_user_options, text="Logout".t)
+
+       # Refesh displayed text
+       fun refresh
+       do
+               lbl_user_options_title.size = 1.5
+               lbl_welcome.set_user_name
+               but_logout.enabled = app.user != null
+       end
+
+       init
+       do
+               but_logout.observers.add self
+               refresh
+       end
+
+       redef fun on_event(event)
+       do
+               if event isa ButtonPressEvent then
+                       var sender = event.sender
+                       if sender == but_logout then
+                               app.user = null
+                               app.token = "none"
+                               app.on_log_in
+                               refresh
+                       end
+               end
+
+               super
+       end
+end
+
+# Window for signing up a new user or logging in
+class SignupWindow
+       super Window
+
+       # Main window layout
+       var layout = new ListLayout(parent=self)
+
+       private var lbl_welcome = new Label(parent=layout, text="Welcome")
+
+       # Name
+       private var name_line = new HorizontalLayout(parent=layout)
+       private var lbl_name = new Label(parent=name_line, text="Username".t)
+       private var txt_name = new TextInput(parent=name_line, text=app.user)
+
+       # Pass
+       private var pass_line = new HorizontalLayout(parent=layout)
+       private var lbl_pass = new Label(parent=pass_line, text="Password".t)
+       private var txt_pass = new TextInput(parent=pass_line, is_password=true)
+       private var lbl_pass_desc = new Label(parent=layout,
+               text="Passwords must be composed of at least 6 characters.".t)
+
+       private var but_login = new Button(parent=layout, text="Login".t)
+
+       # Email
+       private var email_line = new HorizontalLayout(parent=layout)
+       private var lbl_email = new Label(parent=email_line, text="Email".t)
+       private var txt_email = new TextInput(parent=email_line)
+
+       private var but_signup = new Button(parent=layout, text="Signup".t)
+
+       private var lbl_feedback = new Label(parent=layout, text="")
+
+       init
+       do
+               lbl_pass_desc.size = 0.5
+
+               for c in [but_login, but_signup] do
+                       c.observers.add self
+               end
+       end
+
+       redef fun on_event(event)
+       do
+               if debug then print "BenitluxWindow::on_event {event}"
+
+               if event isa ButtonPressEvent then
+                       var sender = event.sender
+                       if sender == but_login or sender == but_signup then
+
+                               var name = txt_name.text
+                               if name == null or not name.name_is_ok then
+                                       feedback "Invalid name".t
+                                       return
+                               end
+
+                               var pass = txt_pass.text
+                               if pass == null or not pass.pass_is_ok then
+                                       feedback "Invalid password".t
+                                       return
+                               end
+
+                               if sender == but_login then
+                                       (new LoginOrSignupAction(self, "rest/login?name={name}&pass={pass.pass_hash}")).start
+                               else if sender == but_signup then
+                                       var email = txt_email.text
+                                       if email == null or email.is_empty then
+                                               feedback "Invalid email".t
+                                               return
+                                       end
+
+                                       (new LoginOrSignupAction(self, "rest/signup?name={name}&pass={pass.pass_hash}&email={email}")).start
+                               end
+                       end
+               end
+
+               super
+       end
+
+       # Show lasting feedback to the user in a label
+       fun feedback(text: String) do lbl_feedback.text = text
+end
+
+# ---
+# Async RESTful actions
+
+# Async request for login in or signing up
+class LoginOrSignupAction
+       super WindowHttpRequest
+
+       redef type W: SignupWindow
+
+       init do affected_views.add_all([window.but_login, window.but_signup])
+
+       redef fun on_load(res)
+       do
+               if intercept_error(res) then return
+
+               if not res isa LoginResult then
+                       on_fail new Error("Server sent unexpected data {res or else "null"}")
+                       return
+               end
+
+               app.token = res.token
+               app.user = res.user.name
+
+               app.on_log_in
+       end
+end
+
+# Async request for signing up
+class SignupAction
+       super WindowHttpRequest
+
+       redef type W: SignupWindow
+
+       init do affected_views.add_all([window.but_signup])
+
+       redef fun on_load(res)
+       do
+               if intercept_error(res) then return
+
+               if not res isa LoginResult then
+                       on_fail new Error("Server sent unexpected data {res or else "null"}")
+                       return
+               end
+
+               app.token = res.token
+               app.user = res.user.name
+               app.on_log_in
+       end
+end