Skip to content

Commit

Permalink
feat: warn user before losing progress on an unfinished practice (#277)
Browse files Browse the repository at this point in the history
Co-authored-by: Nathan Lovato <[email protected]>
  • Loading branch information
mathmods and NathanLovato authored Feb 8, 2022
1 parent 4fb06ee commit c3f4fbd
Show file tree
Hide file tree
Showing 9 changed files with 396 additions and 39 deletions.
131 changes: 101 additions & 30 deletions autoload/NavigationManager.gd
Original file line number Diff line number Diff line change
@@ -1,16 +1,26 @@
extends Node

signal navigation_requested()
signal back_navigation_requested()
signal outliner_navigation_requested()
signal welcome_screen_navigation_requested()
signal navigation_requested
signal back_navigation_requested
signal outliner_navigation_requested
signal welcome_screen_navigation_requested
signal last_screen_unload_requested
signal all_screens_unload_requested

enum UNLOAD_TYPE { BACK, OUTLINER }

const ERROR_WRONG_UNLOAD_TYPE := "Unsupported unload type in NavigationManager! Unload type: %s"

var _url_normalization_regex := RegExpGroup.compile("^(?<prefix>user:\\/\\/|res:\\/\\/|\\.*?\\/+)(?<url>.*)\\.(?<extension>t?res)")
var history := PoolStringArray()
var current_url := "" setget set_current_url, get_current_url
var is_mobile_platform := OS.get_name() in ["Android", "HTML5", "iOS"]
var arguments := {}

var _current_unload_type := -1
var _url_normalization_regex := RegExpGroup.compile(
"^(?<prefix>user:\\/\\/|res:\\/\\/|\\.*?\\/+)(?<url>.*)\\.(?<extension>t?res)"
)


func _init() -> void:
_parse_arguments()
Expand All @@ -21,6 +31,7 @@ func _init() -> void:
if initial_url != "":
navigate_to(initial_url)


func _parse_arguments() -> void:
arguments = {}
for argument in OS.get_cmdline_args():
Expand All @@ -31,29 +42,86 @@ func _parse_arguments() -> void:
arguments[key] = value


# Checks if any resource with active user data is about to be closed.
#
# If the current screen is a Practice it might have code edited. If the current
# screen is a Lesson it might be shadowing a Practice.
func _is_unload_confirmation_required() -> bool:
# For the home screen and outliner, get_current_url() returns "". We use
# that to return false for those screens.
if get_current_url():
var resource = get_navigation_resource(get_current_url())
return resource is Practice or resource is Lesson

return false


func get_history(n := 1) -> String:
if n > history.size():
return ""
return history[history.size() - n]


# Called by any screen that is to be unloaded (but it is not safe/user denied)
func deny_unload() -> void:
_current_unload_type = -1


# Called by any screen that is to be unloaded
func confirm_unload() -> void:
match _current_unload_type:
UNLOAD_TYPE.BACK:
_navigate_back()
UNLOAD_TYPE.OUTLINER:
_navigate_to_outliner()
_:
printerr(ERROR_WRONG_UNLOAD_TYPE % _current_unload_type)

_current_unload_type = -1


# Call to navigate back from within the app. If the user is about to lose data,
# they'll get a popup window preventing them from navigating back until they
# confirm they want to leave the screen.
#
# For browser-only navigation, use _navigate_back() instead.
func navigate_back() -> void:
if _is_unload_confirmation_required():
_current_unload_type = UNLOAD_TYPE.BACK
emit_signal("last_screen_unload_requested")
return

_navigate_back()


func navigate_to_outliner() -> void:
if _is_unload_confirmation_required():
_current_unload_type = UNLOAD_TYPE.OUTLINER
emit_signal("all_screens_unload_requested")
return

_navigate_to_outliner()


# Navigates back instantly, without confirmation popups. Use this for browser
# navigation.
func _navigate_back() -> void:
# Nothing to go back to, open the outliner.
if history.size() < 2:
navigate_to_outliner()
return

history.remove(history.size() - 1)
_js_back()

emit_signal("back_navigation_requested")


func navigate_to_outliner() -> void:
func _navigate_to_outliner() -> void:
# prints("emptying history")
history.resize(0)
_js_to_outliner()

emit_signal("outliner_navigation_requested")


Expand All @@ -64,41 +132,41 @@ func navigate_to_welcome_screen() -> void:
func navigate_to(metadata: String) -> void:
var regex_result := _url_normalization_regex.search(metadata)
if not regex_result:
push_error("`%s` is not a valid resource path"%[metadata])
push_error("`%s` is not a valid resource path" % [metadata])
return
var normalized := NormalizedUrl.new(regex_result)

if not normalized.path:
push_error("`%s` is not a valid path" % metadata)
return

var file_path := normalized.get_file_path()

var resource := get_navigation_resource(file_path)
if not (resource is Resource):
push_error("`%s` is not a resource" % file_path)
return

history.push_back(file_path)
_push_javascript_state(normalized.get_web_url())

emit_signal("navigation_requested")


func get_navigation_resource(resource_id : String) -> Resource:
func get_navigation_resource(resource_id: String) -> Resource:
var is_lesson := resource_id.ends_with("lesson.tres")

if is_lesson:
return load(resource_id) as Resource

var lesson_path := resource_id.get_base_dir().plus_file("lesson.tres")
var lesson_data := load(lesson_path) as Lesson

# If it's not a lesson, it's a practice. May support some other types in future.
for practice_res in lesson_data.practices:
if practice_res.practice_id == resource_id:
return practice_res

return null


Expand Down Expand Up @@ -155,10 +223,10 @@ func _on_init_setup_js() -> void:
if not _js_available:
return
_js_history = JavaScript.get_interface("history")

# if the reference doesn't survive the method call, the callback will be dereferenced
_js_popstate_listener_ref = JavaScript.create_callback(self, "_on_js_popstate")

_js_window = JavaScript.get_interface("window")
# warning-ignore:unsafe_method_access
_js_window.addEventListener("popstate", _js_popstate_listener_ref)
Expand All @@ -167,19 +235,21 @@ func _on_init_setup_js() -> void:
var url: String = (
# warning-ignore:unsafe_property_access
# warning-ignore:unsafe_property_access
_js_window.location.hash.trim_prefix("#").trim_prefix("/") if _js_window.location.hash else ""
_js_window.location.hash.trim_prefix("#").trim_prefix("/")
if _js_window.location.hash
else ""
)
if url:
navigate_to("res://%s"%[url])
navigate_to("res://%s" % [url])


# Handles user changing the url manually or pressing back
func _on_js_popstate(_args: Array) -> void:
# we have set this to `false` either in _js_to_outliner or _js_back, we can set it back to true now
if _temporary_disable_back_listener:
return
# var event = args[0]
navigate_back()
_navigate_back()


# Call this from GDScript to synchronize the browser. Safe to call in all environments, will no-op
# when JS is not available.
Expand Down Expand Up @@ -213,13 +283,15 @@ func _restore_popstate_listener() -> void:
yield(get_tree().create_timer(0.3), "timeout")
_temporary_disable_back_listener = false


# Call this from GDScript to synchronize the browser. Safe to call in all environments, will no-op
# when JS is not available.
func _push_javascript_state(url: String) -> void:
if not _js_available:
return
# warning-ignore:unsafe_method_access
_js_history.pushState(url, '', '#'+url)
_js_history.pushState(url, "", "#" + url)


class NormalizedUrl:
var protocol := ""
Expand All @@ -232,13 +304,12 @@ class NormalizedUrl:
extension = regex_result.get_string("extension")
if protocol in ["//", "/"]:
protocol = "res://"



func get_file_path() -> String:
return "%s%s.%s"%[protocol, path, extension]
return "%s%s.%s" % [protocol, path, extension]

func get_web_url() -> String:
return "%s.%s"%[path, extension]
return "%s.%s" % [path, extension]

func _to_string() -> String:
return protocol + path
10 changes: 8 additions & 2 deletions project.godot
Original file line number Diff line number Diff line change
Expand Up @@ -239,17 +239,22 @@ _global_script_classes=[ {
"language": "GDScript",
"path": "res://ui/screens/lesson/UIContentBlock.gd"
}, {
"base": "Control",
"base": "UINavigatablePage",
"class": "UILesson",
"language": "GDScript",
"path": "res://ui/UILesson.gd"
}, {
"base": "Control",
"class": "UINavigatablePage",
"language": "GDScript",
"path": "res://ui/UINavigatablePage.gd"
}, {
"base": "PanelContainer",
"class": "UINavigator",
"language": "GDScript",
"path": "res://ui/UINavigator.gd"
}, {
"base": "Control",
"base": "UINavigatablePage",
"class": "UIPractice",
"language": "GDScript",
"path": "res://ui/UIPractice.gd"
Expand Down Expand Up @@ -327,6 +332,7 @@ _global_script_class_icons={
"UIBaseQuiz": "",
"UIContentBlock": "",
"UILesson": "",
"UINavigatablePage": "",
"UINavigator": "",
"UIPractice": "",
"UIPracticeButton": "",
Expand Down
2 changes: 1 addition & 1 deletion ui/UILesson.gd
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
# When pressing a practice button, emits an event so the navigation can
# transition to the practice screen.
class_name UILesson
extends Control
extends UINavigatablePage

const ContentBlockScene := preload("screens/lesson/UIContentBlock.tscn")
const QuizInputFieldScene := preload("screens/lesson/quizzes/UIQuizInputField.tscn")
Expand Down
52 changes: 52 additions & 0 deletions ui/UINavigatablePage.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Base class for lesson and practice pages. Provides an interface for navigation
# in relation with NavigationManager.
#
# Defines required overridable functions for interaction with
# NavigationManager.gd.
#
# Defaults to always accept being unlaoded unless overriden.
class_name UINavigatablePage
extends Control

var _is_current_screen := false


func _ready() -> void:
NavigationManager.connect(
"all_screens_unload_requested", self, "_on_all_screens_unload_requested"
)


func set_is_current_screen(value: bool) -> void:
if _is_current_screen == value:
return

_is_current_screen = value

if _is_current_screen:
NavigationManager.connect(
"last_screen_unload_requested", self, "_on_current_screen_unload_requested"
)
else:
NavigationManager.disconnect(
"last_screen_unload_requested", self, "_on_current_screen_unload_requested"
)


func _accept_unload() -> void:
NavigationManager.confirm_unload()


func _deny_unload() -> void:
NavigationManager.deny_unload()


# Overridde if unload requires waiting
func _on_current_screen_unload_requested() -> void:
_accept_unload()


# Overridde if unload requires waiting
func _on_all_screens_unload_requested() -> void:
if _is_current_screen:
_on_current_screen_unload_requested()
12 changes: 8 additions & 4 deletions ui/UINavigator.gd
Original file line number Diff line number Diff line change
Expand Up @@ -99,14 +99,16 @@ func _navigate_back() -> void:
_navigate_to_outliner()
return

var current_screen: Control = _screens_stack.pop_back()
var next_screen: Control = _screens_stack.back()
var current_screen: UINavigatablePage = _screens_stack.pop_back()
var next_screen: UINavigatablePage = _screens_stack.back()
_update_back_button(_screens_stack.size() < 2)

# warning-ignore:unsafe_method_access
var target = next_screen.get_screen_resource()
_breadcrumbs.update_breadcrumbs(course, target)

next_screen.set_is_current_screen(true)

_transition_to(next_screen, current_screen, false)
yield(self, "transition_completed")
current_screen.queue_free()
Expand Down Expand Up @@ -135,7 +137,7 @@ func _navigate_to_outliner() -> void:
# Navigates forward to the next screen and adds it to the stack.
func _navigate_to() -> void:
var target := NavigationManager.get_navigation_resource(NavigationManager.current_url)
var screen: Control
var screen: UINavigatablePage
if target is Practice:
var lesson = course.lessons[_lesson_index]

Expand All @@ -160,12 +162,14 @@ func _navigate_to() -> void:

var has_previous_screen = not _screens_stack.empty()
_screens_stack.push_back(screen)
screen.set_is_current_screen(true)
_back_button.show()
_update_back_button(_screens_stack.size() < 2)

_screen_container.add_child(screen)
if has_previous_screen:
var previous_screen: Control = _screens_stack[-2]
var previous_screen: UINavigatablePage = _screens_stack[-2]
previous_screen.set_is_current_screen(false)
_transition_to(screen, previous_screen)
yield(self, "transition_completed")

Expand Down
Loading

0 comments on commit c3f4fbd

Please sign in to comment.