ruflet_rails 0.0.13 → 0.0.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +165 -62
- data/lib/generators/ruflet/install/install_generator.rb +3 -3
- data/lib/ruflet/rails/configuration.rb +13 -7
- data/lib/ruflet/rails/install_support.rb +19 -637
- data/lib/ruflet/rails/native_app/actions.rb +167 -0
- data/lib/ruflet/rails/native_app/customization.rb +118 -0
- data/lib/ruflet/rails/native_app/html_adapter.rb +248 -0
- data/lib/ruflet/rails/native_app/navigation.rb +645 -0
- data/lib/ruflet/rails/native_app/overlays.rb +222 -0
- data/lib/ruflet/rails/native_app/shell.rb +390 -0
- data/lib/ruflet/rails/native_app.rb +25 -222
- data/lib/ruflet/rails/protocol/web_app.rb +1 -1
- data/lib/ruflet/rails/railtie.rb +3 -20
- data/lib/ruflet/rails/view_helpers.rb +205 -0
- data/lib/ruflet/rails.rb +22 -55
- data/lib/ruflet/version.rb +1 -1
- data/lib/ruflet_rails.rb +0 -4
- metadata +14 -15
- data/lib/generators/ruflet/form/form_generator.rb +0 -55
- data/lib/generators/ruflet/scaffold/scaffold_generator.rb +0 -54
- data/lib/ruflet/rails/form_helpers.rb +0 -161
- data/lib/ruflet/rails/generator_hooks.rb +0 -25
- data/lib/ruflet/rails/resource_component.rb +0 -370
- data/lib/ruflet/rails/route_stack.rb +0 -90
- data/lib/ruflet/rails/webview_app.rb +0 -54
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ruflet
|
|
4
|
+
module Rails
|
|
5
|
+
class NativeApp
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
# --- Native dialog / toast ---------------------------------------------
|
|
9
|
+
|
|
10
|
+
# `ruflet-action="dialog"`: open a native AlertDialog. A confirm button
|
|
11
|
+
# (when given) navigates; OK just dismisses.
|
|
12
|
+
def show_native_dialog(spec)
|
|
13
|
+
title = spec["title"].to_s
|
|
14
|
+
content = spec["content"].to_s
|
|
15
|
+
actions = []
|
|
16
|
+
|
|
17
|
+
confirm = spec["confirm"].to_s
|
|
18
|
+
unless confirm.empty?
|
|
19
|
+
url = spec["url"].to_s
|
|
20
|
+
mode = (spec["action"] || spec["mode"] || "push").to_s
|
|
21
|
+
actions << text_button(
|
|
22
|
+
confirm,
|
|
23
|
+
on_click: lambda do |_event|
|
|
24
|
+
close_dialog
|
|
25
|
+
navigate_screen(url, mode) unless url.empty?
|
|
26
|
+
end
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
actions << text_button("OK", on_click: ->(_event) { close_dialog })
|
|
30
|
+
|
|
31
|
+
args = ruflet_props_for(:alert_dialog, spec).merge(
|
|
32
|
+
actions: actions,
|
|
33
|
+
open: true,
|
|
34
|
+
adaptive: adaptive?(spec),
|
|
35
|
+
on_dismiss: ->(_event) { @dialog = nil }
|
|
36
|
+
)
|
|
37
|
+
args[:title] = text(title, **nested_ruflet_props(spec, "title_props", control: :text)) unless title.empty?
|
|
38
|
+
args[:content] = text(content, **nested_ruflet_props(spec, "content_props", control: :text)) unless content.empty?
|
|
39
|
+
@dialog = alert_dialog(**args)
|
|
40
|
+
@page.show_dialog(@dialog)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def close_dialog
|
|
44
|
+
return unless @dialog
|
|
45
|
+
|
|
46
|
+
@page.update(@dialog, open: false)
|
|
47
|
+
@dialog = nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# `ruflet-action="toast"`: show a native SnackBar.
|
|
51
|
+
def show_toast(spec)
|
|
52
|
+
message = spec["message"].to_s
|
|
53
|
+
return if message.empty?
|
|
54
|
+
|
|
55
|
+
args = ruflet_props_for(:snack_bar, spec).merge(
|
|
56
|
+
content: text(message, **nested_ruflet_props(spec, "content_props", "text_props", control: :text)),
|
|
57
|
+
open: true,
|
|
58
|
+
adaptive: adaptive?(spec)
|
|
59
|
+
)
|
|
60
|
+
duration = spec["duration"].to_s
|
|
61
|
+
args[:duration] = duration.to_i if duration =~ /\A\d+\z/
|
|
62
|
+
@page.snackbar = snack_bar(**args)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# `ruflet-action="menu"` or an AppBar action with `action: "menu"`:
|
|
66
|
+
# present a native list of actions. This keeps Rails ERB as the source of
|
|
67
|
+
# truth while avoiding DOM dropdowns inside the WebView chrome.
|
|
68
|
+
def show_native_menu(spec)
|
|
69
|
+
items = Array(spec["items"])
|
|
70
|
+
return if items.empty?
|
|
71
|
+
|
|
72
|
+
controls = []
|
|
73
|
+
title = spec["title"].to_s
|
|
74
|
+
unless title.empty?
|
|
75
|
+
controls << container(
|
|
76
|
+
padding: { left: 16, right: 16, top: 8, bottom: 8 },
|
|
77
|
+
content: text(title, weight: "bold", size: 16, color: "#0F172A")
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
items.each do |item|
|
|
82
|
+
controls << menu_tile(item)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
@sheet = bottomsheet(
|
|
86
|
+
container(
|
|
87
|
+
padding: { left: 8, right: 8, bottom: 12 },
|
|
88
|
+
content: column(controls: controls, tight: true, spacing: 2)
|
|
89
|
+
),
|
|
90
|
+
open: true,
|
|
91
|
+
adaptive: true,
|
|
92
|
+
dismissible: true,
|
|
93
|
+
draggable: true,
|
|
94
|
+
scrollable: true,
|
|
95
|
+
show_drag_handle: true,
|
|
96
|
+
use_safe_area: true,
|
|
97
|
+
on_dismiss: ->(_event) { sheet_dismissed }
|
|
98
|
+
)
|
|
99
|
+
@page.bottom_sheet = @sheet
|
|
100
|
+
@page.update
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def menu_tile(source)
|
|
104
|
+
item = source.is_a?(Hash) ? source : { "label" => source.to_s }
|
|
105
|
+
label = item["label"].to_s
|
|
106
|
+
icon_name = item["icon"].to_s
|
|
107
|
+
target = item["url"].to_s
|
|
108
|
+
mode = (item["action"] || item["mode"] || "root").to_s
|
|
109
|
+
|
|
110
|
+
list_tile(
|
|
111
|
+
leading: icon_name.empty? ? nil : icon(icon_name, **nested_ruflet_props(item, "icon_props", control: :icon)),
|
|
112
|
+
title: text(label, **nested_ruflet_props(item, "label_props", "title_props", control: :text)),
|
|
113
|
+
selected: item["selected"] == true,
|
|
114
|
+
selected_tile_color: item["selected_tile_color"] || "#ECFDF5",
|
|
115
|
+
on_click: lambda do |_event|
|
|
116
|
+
action = lambda do
|
|
117
|
+
navigate_screen(target, mode, item) unless target.empty?
|
|
118
|
+
end
|
|
119
|
+
close_sheet_for?(item) ? close_sheet(&action) : action.call
|
|
120
|
+
end,
|
|
121
|
+
**ruflet_props_for(:list_tile, item)
|
|
122
|
+
)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def close_sheet_for?(spec)
|
|
126
|
+
return true unless spec.is_a?(Hash) && spec.key?("close")
|
|
127
|
+
|
|
128
|
+
![false, "false", "0", 0, "no", "off"].include?(spec["close"])
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def close_sheet(&after_close)
|
|
132
|
+
sheet = @sheet || @page.instance_variable_get(:@bottom_sheet)
|
|
133
|
+
unless sheet
|
|
134
|
+
after_close.call if after_close
|
|
135
|
+
return
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
@after_sheet_close = after_close if after_close
|
|
139
|
+
@page.update(sheet, open: false) if sheet.wire_id
|
|
140
|
+
rescue StandardError
|
|
141
|
+
sheet_dismissed
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def sheet_dismissed
|
|
145
|
+
after_close = @after_sheet_close
|
|
146
|
+
@after_sheet_close = nil
|
|
147
|
+
@sheet = nil
|
|
148
|
+
@page.bottom_sheet = nil if @page.respond_to?(:bottom_sheet=)
|
|
149
|
+
after_close.call if after_close
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# --- Bottom-sheet modal (web content) ----------------------------------
|
|
153
|
+
|
|
154
|
+
SHEET_PADDING = 20 # inset the page from the sheet edges (top clears the handle)
|
|
155
|
+
SHEET_HEIGHT_RATIO = 0.82 # short enough to leave a clear peek of the screen
|
|
156
|
+
|
|
157
|
+
# `ruflet-action="sheet"`: present a URL as a bottom-sheet modal. It must
|
|
158
|
+
# READ as a modal — a sheet that slides up over the current screen with a
|
|
159
|
+
# drag handle, rounded top, and a clear peek of the page behind it, not a
|
|
160
|
+
# fullscreen container indistinguishable from a pushed page. The webview has
|
|
161
|
+
# no intrinsic size, so the card is explicitly sized to leave that peek; the
|
|
162
|
+
# uniform padding then insets the page (the top inset clears the handle).
|
|
163
|
+
def present_sheet(source)
|
|
164
|
+
spec = source.is_a?(Hash) ? source : { "url" => source }
|
|
165
|
+
url = absolute_url(spec["url"])
|
|
166
|
+
return if url.empty?
|
|
167
|
+
|
|
168
|
+
sheet_webview = nil
|
|
169
|
+
sheet_webview = webview(
|
|
170
|
+
url: url, method: "get", enable_javascript: true,
|
|
171
|
+
on_page_started: ->(_event) { enable_js(sheet_webview) },
|
|
172
|
+
on_page_ended: ->(_event) { inject_html_adapter_for(sheet_webview, sheet: true) },
|
|
173
|
+
on_console_message: ->(event) { handle_sheet_message(message_of(event)) }
|
|
174
|
+
)
|
|
175
|
+
card = container(
|
|
176
|
+
content: sheet_webview,
|
|
177
|
+
width: sheet_card_width,
|
|
178
|
+
height: sheet_card_height,
|
|
179
|
+
padding: SHEET_PADDING,
|
|
180
|
+
**nested_ruflet_props(spec, "card_props", "container_props", control: :container)
|
|
181
|
+
)
|
|
182
|
+
@sheet = bottomsheet(
|
|
183
|
+
card,
|
|
184
|
+
**ruflet_props_for(:bottom_sheet, spec),
|
|
185
|
+
open: true,
|
|
186
|
+
adaptive: true,
|
|
187
|
+
dismissible: spec.key?("dismissible") ? spec["dismissible"] : true,
|
|
188
|
+
draggable: spec.key?("draggable") ? spec["draggable"] : true,
|
|
189
|
+
scrollable: spec.key?("scrollable") ? spec["scrollable"] : true,
|
|
190
|
+
show_drag_handle: spec.key?("show_drag_handle") ? spec["show_drag_handle"] : true,
|
|
191
|
+
use_safe_area: spec.key?("use_safe_area") ? spec["use_safe_area"] : true,
|
|
192
|
+
on_dismiss: ->(_event) { sheet_dismissed }
|
|
193
|
+
)
|
|
194
|
+
@page.bottom_sheet = @sheet
|
|
195
|
+
@page.update
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Card dimensions from the live viewport (the client reports it via the
|
|
199
|
+
# resize event). Full width; height leaves a clear peek of the screen above
|
|
200
|
+
# the sheet. Falls back to sensible phone dimensions before the first
|
|
201
|
+
# resize report.
|
|
202
|
+
def sheet_card_width
|
|
203
|
+
numeric_or(@page.respond_to?(:width) ? @page.width : nil, 430.0).round
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def sheet_card_height
|
|
207
|
+
viewport = numeric_or(@page.respond_to?(:height) ? @page.height : nil, 800.0)
|
|
208
|
+
(viewport * SHEET_HEIGHT_RATIO).round
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def numeric_or(value, fallback)
|
|
212
|
+
value.is_a?(Numeric) && value.positive? ? value.to_f : fallback
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def adaptive?(spec)
|
|
216
|
+
return false if spec.is_a?(Hash) && spec["adaptive"] == false
|
|
217
|
+
|
|
218
|
+
true
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ruflet
|
|
4
|
+
module Rails
|
|
5
|
+
class NativeApp
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
# --- Stack operations ---------------------------------------------------
|
|
9
|
+
|
|
10
|
+
def push_webview(url, root: false, spec: nil)
|
|
11
|
+
url = absolute_url(url)
|
|
12
|
+
return if url.empty?
|
|
13
|
+
|
|
14
|
+
screen = Screen.new(url: url)
|
|
15
|
+
screen.appbar_spec = appbar_spec_from(spec)
|
|
16
|
+
screen.appbar_spec ||= default_screen_appbar_spec(url) unless root
|
|
17
|
+
screen.title_text = build_title_text(screen)
|
|
18
|
+
screen.loading = build_loading_state
|
|
19
|
+
screen.webview = build_webview(screen)
|
|
20
|
+
screen.body = build_webview_body(screen)
|
|
21
|
+
screen.view = root ? build_view(screen) : @screens.first&.view
|
|
22
|
+
@screens.push(screen)
|
|
23
|
+
root ? flush : apply_screen_to_shell(screen)
|
|
24
|
+
screen
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def pop
|
|
28
|
+
return if @screens.size <= 1
|
|
29
|
+
|
|
30
|
+
@screens.pop
|
|
31
|
+
apply_screen_to_shell(@screens.last)
|
|
32
|
+
close_drawer(@screens.last)
|
|
33
|
+
sync_drawer_selection(@screens.last)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def flush
|
|
37
|
+
@page.views = [@screens.last&.view].compact
|
|
38
|
+
@page.update
|
|
39
|
+
reconcile_loading_visibility
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# --- Webview screen + native chrome ------------------------------------
|
|
43
|
+
|
|
44
|
+
def build_webview(screen)
|
|
45
|
+
webview(
|
|
46
|
+
url: screen.url,
|
|
47
|
+
method: "get",
|
|
48
|
+
expand: true,
|
|
49
|
+
enable_javascript: true,
|
|
50
|
+
# The mobile client defaults JavaScriptMode to disabled and ignores the
|
|
51
|
+
# enable_javascript prop, so switch JS on as each page starts loading.
|
|
52
|
+
# Without it the in-page adapter cannot read ERB data-ruflet
|
|
53
|
+
# declarations, and WebAuthn/passkeys will not work.
|
|
54
|
+
on_page_started: lambda do |event|
|
|
55
|
+
update_screen_url_from_event(screen, event)
|
|
56
|
+
show_loading(screen)
|
|
57
|
+
enable_js(screen.webview)
|
|
58
|
+
end,
|
|
59
|
+
on_page_ended: lambda do |event|
|
|
60
|
+
update_screen_url_from_event(screen, event)
|
|
61
|
+
inject_html_adapter(screen)
|
|
62
|
+
hide_loading(screen)
|
|
63
|
+
end,
|
|
64
|
+
on_url_change: ->(event) { update_screen_url_from_event(screen, event) },
|
|
65
|
+
on_web_resource_error: ->(_event) { hide_loading(screen) },
|
|
66
|
+
on_console_message: ->(event) { handle_message(screen, message_of(event)) }
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def build_webview_body(screen)
|
|
71
|
+
return screen.webview unless screen.loading
|
|
72
|
+
|
|
73
|
+
stack(
|
|
74
|
+
[screen.webview, screen.loading],
|
|
75
|
+
fit: "expand",
|
|
76
|
+
expand: true
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def update_screen_url_from_event(screen, event)
|
|
81
|
+
url = webview_event_url(event)
|
|
82
|
+
return if url.empty?
|
|
83
|
+
|
|
84
|
+
screen.url = absolute_url(url)
|
|
85
|
+
screen.webview.props["url"] = screen.url if screen.webview
|
|
86
|
+
rescue StandardError
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def webview_event_url(event)
|
|
91
|
+
data = event.respond_to?(:data) ? event.data : event
|
|
92
|
+
return data if data.is_a?(String)
|
|
93
|
+
return "" unless data.is_a?(Hash)
|
|
94
|
+
|
|
95
|
+
(data["url"] || data[:url] || data["value"] || data[:value] || data["href"] || data[:href]).to_s
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def build_view(screen)
|
|
99
|
+
args = { route: "/" }
|
|
100
|
+
appbar = build_appbar(screen)
|
|
101
|
+
args[:appbar] = appbar if appbar
|
|
102
|
+
args[:drawer] = screen.drawer if screen.drawer
|
|
103
|
+
args[:end_drawer] = screen.end_drawer if screen.end_drawer
|
|
104
|
+
args[:navigation_bar] = @navigation_bar unless @navigation_bar.nil?
|
|
105
|
+
args[:bottom_appbar] = @bottom_appbar unless @bottom_appbar.nil?
|
|
106
|
+
view(screen_controls(screen), **args)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def screen_controls(screen)
|
|
110
|
+
body = screen.body || screen.webview
|
|
111
|
+
return [body] unless screen.rail
|
|
112
|
+
|
|
113
|
+
[
|
|
114
|
+
row(
|
|
115
|
+
[screen.rail, body],
|
|
116
|
+
expand: true,
|
|
117
|
+
spacing: 0
|
|
118
|
+
)
|
|
119
|
+
]
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Native chrome is opt-in: render an AppBar only when the app asks for one
|
|
123
|
+
# (a title or actions) or the page declared one via `ruflet-appbar`
|
|
124
|
+
# (screen.appbar_spec). With neither, the screen is a full-bleed webview —
|
|
125
|
+
# the web page already renders its own header, so a native AppBar on top of
|
|
126
|
+
# it just duplicates that chrome.
|
|
127
|
+
def build_appbar(screen)
|
|
128
|
+
spec = screen.appbar_spec
|
|
129
|
+
actions = spec ? spec[:actions] : resolve_actions
|
|
130
|
+
leading = spec ? spec[:leading] : nil
|
|
131
|
+
has_title = spec ? true : !@title.to_s.strip.empty?
|
|
132
|
+
return nil if !has_title && !leading && Array(actions).empty?
|
|
133
|
+
|
|
134
|
+
args = spec ? spec[:props].dup : {}
|
|
135
|
+
args[:title] = screen.title_text
|
|
136
|
+
if leading
|
|
137
|
+
args[:leading] = leading
|
|
138
|
+
args[:automatically_imply_leading] = false
|
|
139
|
+
end
|
|
140
|
+
args[:actions] = actions unless Array(actions).empty?
|
|
141
|
+
appbar(**args)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def build_title_text(screen)
|
|
145
|
+
text(title_for(screen), **(screen.appbar_spec ? screen.appbar_spec[:title_props] : {}))
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def title_for(screen)
|
|
149
|
+
title = screen.appbar_spec&.fetch(:title, nil)
|
|
150
|
+
title.nil? ? @title : title.to_s
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def appbar_spec_from(spec)
|
|
154
|
+
return nil unless spec.is_a?(Hash)
|
|
155
|
+
|
|
156
|
+
title = spec["title"]
|
|
157
|
+
leading = drawer_leading?(spec["leading"]) ? nil : build_chrome_button(spec["leading"])
|
|
158
|
+
actions = Array(spec["actions"]).filter_map { |action| build_chrome_button(action) }
|
|
159
|
+
props = ruflet_props_for(:appbar, spec)
|
|
160
|
+
title_props = nested_ruflet_props(spec, "title_props", "title_text_props", control: :text)
|
|
161
|
+
return nil if title.to_s.strip.empty? && leading.nil? && actions.empty? && props.empty?
|
|
162
|
+
|
|
163
|
+
{ title: title.to_s, leading: leading, actions: actions, props: props, title_props: title_props }
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def default_screen_appbar_spec(url)
|
|
167
|
+
{
|
|
168
|
+
title: title_from_url(url),
|
|
169
|
+
leading: build_chrome_button({ "icon" => "close", "action" => "back" }),
|
|
170
|
+
actions: [],
|
|
171
|
+
props: {},
|
|
172
|
+
title_props: {}
|
|
173
|
+
}
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def title_from_url(url)
|
|
177
|
+
path = URI.parse(url.to_s).path.to_s
|
|
178
|
+
label = path.split("/").reject(&:empty?).last.to_s
|
|
179
|
+
label = "Screen" if label.empty?
|
|
180
|
+
label.tr("_-", " ").split.map(&:capitalize).join(" ")
|
|
181
|
+
rescue URI::InvalidURIError
|
|
182
|
+
"Screen"
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def build_chrome_button(spec)
|
|
186
|
+
spec = { "icon" => spec } unless spec.is_a?(Hash)
|
|
187
|
+
icon = spec["icon"].to_s
|
|
188
|
+
return nil if icon.empty?
|
|
189
|
+
|
|
190
|
+
mode = (spec["action"] || spec["mode"] || "push").to_s
|
|
191
|
+
url = spec["url"].to_s
|
|
192
|
+
icon_button(
|
|
193
|
+
icon,
|
|
194
|
+
**ruflet_props_for(:icon_button, spec),
|
|
195
|
+
on_click: lambda do |_event|
|
|
196
|
+
if mode == "back"
|
|
197
|
+
pop
|
|
198
|
+
elsif mode == "drawer"
|
|
199
|
+
show_drawer
|
|
200
|
+
elsif mode == "end_drawer"
|
|
201
|
+
show_end_drawer
|
|
202
|
+
elsif mode == "menu"
|
|
203
|
+
show_native_menu(spec)
|
|
204
|
+
elsif !url.empty?
|
|
205
|
+
navigate_screen(url, mode, spec)
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def drawer_leading?(spec)
|
|
212
|
+
spec.is_a?(Hash) && %w[drawer end_drawer].include?((spec["action"] || spec["mode"]).to_s)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def resolve_actions
|
|
216
|
+
@actions.respond_to?(:call) ? @actions.call : @actions
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Rebuild the mounted shell if chrome arrives before the initial view has
|
|
220
|
+
# a client wire id; otherwise patch the existing shell in place.
|
|
221
|
+
def rebuild_view(screen)
|
|
222
|
+
if screen.equal?(@screens.first) && @screens.length == 1
|
|
223
|
+
screen.view = build_view(screen)
|
|
224
|
+
flush
|
|
225
|
+
else
|
|
226
|
+
apply_screen_to_shell(screen)
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def update_screen_appbar(screen)
|
|
231
|
+
appbar = build_appbar(screen)
|
|
232
|
+
return rebuild_view(screen) unless screen.view&.wire_id
|
|
233
|
+
|
|
234
|
+
@page.update(screen.view, appbar: appbar)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def update_screen_drawer(screen, drawer:, end_drawer: :__ruflet_rails_unchanged)
|
|
238
|
+
screen.drawer = drawer
|
|
239
|
+
screen.end_drawer = end_drawer unless end_drawer == :__ruflet_rails_unchanged
|
|
240
|
+
return rebuild_view(screen) unless screen.view&.wire_id
|
|
241
|
+
|
|
242
|
+
props = { drawer: drawer }
|
|
243
|
+
props[:end_drawer] = end_drawer unless end_drawer == :__ruflet_rails_unchanged
|
|
244
|
+
@page.update(screen.view, **props)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def update_screen_controls(screen)
|
|
248
|
+
return rebuild_view(screen) unless screen.view&.wire_id
|
|
249
|
+
|
|
250
|
+
@page.update(screen.view, controls: screen_controls(screen))
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def apply_screen_to_shell(screen)
|
|
254
|
+
shell = @screens.first
|
|
255
|
+
return unless screen && shell&.view
|
|
256
|
+
|
|
257
|
+
screen.view = shell.view
|
|
258
|
+
appbar = build_appbar(screen)
|
|
259
|
+
props = {
|
|
260
|
+
controls: screen_controls(screen),
|
|
261
|
+
appbar: appbar,
|
|
262
|
+
drawer: shell.drawer || screen.drawer,
|
|
263
|
+
end_drawer: shell.end_drawer || screen.end_drawer,
|
|
264
|
+
navigation_bar: screen.equal?(shell) ? @navigation_bar : nil,
|
|
265
|
+
bottom_appbar: screen.equal?(shell) ? @bottom_appbar : nil
|
|
266
|
+
}
|
|
267
|
+
if shell.view.wire_id
|
|
268
|
+
@page.update(shell.view, **props)
|
|
269
|
+
else
|
|
270
|
+
props.each { |key, value| shell.view.props[key.to_s] = value }
|
|
271
|
+
flush
|
|
272
|
+
end
|
|
273
|
+
reconcile_loading_visibility
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def update_root_navigation_bar
|
|
277
|
+
root = @screens.first
|
|
278
|
+
return unless root
|
|
279
|
+
return rebuild_view(root) unless root.view&.wire_id
|
|
280
|
+
|
|
281
|
+
@page.update(root.view, navigation_bar: @navigation_bar)
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def build_loading_state
|
|
285
|
+
return nil unless @loading
|
|
286
|
+
return @loading if @loading.is_a?(Ruflet::Control)
|
|
287
|
+
|
|
288
|
+
loading_spec = @loading.is_a?(Hash) ? ruflet_stringify_nested(@loading) : {}
|
|
289
|
+
loading_type = (loading_spec["type"] || loading_spec["component"]).to_s
|
|
290
|
+
if loading_type == "text"
|
|
291
|
+
label = loading_spec["label"] || loading_spec["text"] || loading_spec["message"] || "Loading..."
|
|
292
|
+
return build_text_loading_state(label, loading_spec)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
if @loading.is_a?(String) || (@loading.respond_to?(:to_sym) && @loading.to_sym == :text)
|
|
296
|
+
return build_text_loading_state(@loading.is_a?(String) ? @loading : "Loading...")
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
container(
|
|
300
|
+
bgcolor: "#F7F8FB",
|
|
301
|
+
left: 0,
|
|
302
|
+
right: 0,
|
|
303
|
+
top: 0,
|
|
304
|
+
bottom: 0,
|
|
305
|
+
padding: 24,
|
|
306
|
+
visible: true,
|
|
307
|
+
**nested_ruflet_props(loading_spec, "container_props", control: :container),
|
|
308
|
+
content: shimmer(
|
|
309
|
+
base_color: "#E5E7EB",
|
|
310
|
+
highlight_color: "#F8FAFC",
|
|
311
|
+
**nested_ruflet_props(loading_spec, "shimmer_props", control: :shimmer),
|
|
312
|
+
content: column(
|
|
313
|
+
loading_bars(loading_spec),
|
|
314
|
+
spacing: 18,
|
|
315
|
+
expand: true
|
|
316
|
+
)
|
|
317
|
+
)
|
|
318
|
+
)
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def build_text_loading_state(label, spec = {})
|
|
322
|
+
container(
|
|
323
|
+
bgcolor: "#F7F8FB",
|
|
324
|
+
left: 0,
|
|
325
|
+
right: 0,
|
|
326
|
+
top: 0,
|
|
327
|
+
bottom: 0,
|
|
328
|
+
alignment: "center",
|
|
329
|
+
visible: true,
|
|
330
|
+
**nested_ruflet_props(spec, "container_props", control: :container),
|
|
331
|
+
content: text(label, **nested_ruflet_props(spec, "text_props", control: :text))
|
|
332
|
+
)
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def loading_bars(spec = {})
|
|
336
|
+
bar_props = nested_ruflet_props(spec, "bar_props", control: :container)
|
|
337
|
+
[
|
|
338
|
+
loading_bar(width: 190, height: 28, props: bar_props),
|
|
339
|
+
loading_bar(width: 320, height: 16, props: bar_props),
|
|
340
|
+
loading_bar(width: 280, height: 16, props: bar_props),
|
|
341
|
+
loading_gap(14),
|
|
342
|
+
loading_bar(width: 340, height: 86, props: bar_props),
|
|
343
|
+
loading_bar(width: 300, height: 86, props: bar_props),
|
|
344
|
+
loading_bar(width: 330, height: 86, props: bar_props)
|
|
345
|
+
]
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def loading_bar(width:, height:, props: {})
|
|
349
|
+
container(
|
|
350
|
+
width: width,
|
|
351
|
+
height: height,
|
|
352
|
+
bgcolor: "#E5E7EB",
|
|
353
|
+
border_radius: 10,
|
|
354
|
+
**props
|
|
355
|
+
)
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def loading_gap(height)
|
|
359
|
+
container(height: height)
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def show_loading(screen)
|
|
363
|
+
return unless screen&.loading
|
|
364
|
+
return if screen.loading.props["visible"] == true
|
|
365
|
+
|
|
366
|
+
screen.loading.props["visible"] = true
|
|
367
|
+
@page.update(screen.loading, visible: true) if screen.loading.wire_id
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def hide_loading(screen)
|
|
371
|
+
return unless screen&.loading
|
|
372
|
+
return if screen.loading.props["visible"] == false
|
|
373
|
+
|
|
374
|
+
screen.loading.props["visible"] = false
|
|
375
|
+
@page.update(screen.loading, visible: false) if screen.loading.wire_id
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def reconcile_loading_visibility
|
|
379
|
+
@screens.each do |screen|
|
|
380
|
+
next unless screen.loading&.wire_id
|
|
381
|
+
next unless screen.loading.props.key?("visible")
|
|
382
|
+
|
|
383
|
+
@page.update(screen.loading, visible: screen.loading.props["visible"])
|
|
384
|
+
end
|
|
385
|
+
rescue StandardError
|
|
386
|
+
nil
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
end
|