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)
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)/
--- /dev/null
+# 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
--- /dev/null
+# 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
--- /dev/null
+# 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
--- /dev/null
+# 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
--- /dev/null
+# 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
--- /dev/null
+# 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
--- /dev/null
+# 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
--- /dev/null
+# 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
--- /dev/null
+# 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
--- /dev/null
+# 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