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,645 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ruflet
|
|
4
|
+
module Rails
|
|
5
|
+
class NativeApp
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
# Screen navigation modes:
|
|
9
|
+
# push - add Ruby-side history, then patch the native shell body
|
|
10
|
+
# replace - swap the current history entry for a new body URL
|
|
11
|
+
# root - reset history to the root shell body
|
|
12
|
+
# sheet - present the URL as a bottom-sheet web modal
|
|
13
|
+
# back - restore the previous history entry
|
|
14
|
+
def navigate_screen(url, mode, spec = nil)
|
|
15
|
+
url = absolute_url(url)
|
|
16
|
+
mode = mode.to_s
|
|
17
|
+
mode = "push" if mode.empty?
|
|
18
|
+
if bottomnav_item_for(url) && !%w[back sheet].include?(mode)
|
|
19
|
+
mode = "root"
|
|
20
|
+
spec = bottomnav_appbar_spec(url, spec)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# A bottom sheet corrupts the shell Scaffold on the client: afterwards it
|
|
24
|
+
# silently drops BOTH targeted control patches (so a navigation's new
|
|
25
|
+
# WebView never mounts) AND method invokes (so the drawer won't
|
|
26
|
+
# open/close). Reusing that Scaffold never recovers. So when a sheet was
|
|
27
|
+
# active, rebuild a fresh root view — a brand-new Scaffold the client
|
|
28
|
+
# wires up from scratch, which restores navigation and the drawer.
|
|
29
|
+
resume_from_sheet = mode == "sheet" ? false : consume_pending_sheet
|
|
30
|
+
if resume_from_sheet && !%w[back sheet].include?(mode)
|
|
31
|
+
close_drawer
|
|
32
|
+
@screens.clear
|
|
33
|
+
push_webview(url, root: true, spec: spec)
|
|
34
|
+
return
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
case mode
|
|
38
|
+
when "back" then pop
|
|
39
|
+
when "root" then go_webview(url, spec: spec)
|
|
40
|
+
when "replace" then replace_webview(url, spec: spec)
|
|
41
|
+
when "sheet" then present_sheet((spec || {}).merge("url" => url))
|
|
42
|
+
else push_webview(url, spec: spec)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Drop any open/lingering bottom sheet from both the native_app reference
|
|
47
|
+
# and the ruflet page overlay. Returns whether a sheet was present, so the
|
|
48
|
+
# caller can force a full re-render to recover the client view tree.
|
|
49
|
+
def consume_pending_sheet
|
|
50
|
+
active = !@sheet.nil? || !@page.instance_variable_get(:@bottom_sheet).nil?
|
|
51
|
+
@sheet = nil
|
|
52
|
+
@page.bottom_sheet = nil if active && @page.respond_to?(:bottom_sheet=)
|
|
53
|
+
active
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Root navigation keeps the mounted Ruflet shell (AppBar / drawer / bottom
|
|
57
|
+
# chrome) alive and swaps only the WebView body.
|
|
58
|
+
def go_webview(url, spec: nil)
|
|
59
|
+
url = absolute_url(url)
|
|
60
|
+
return if url.empty?
|
|
61
|
+
|
|
62
|
+
root = @screens.first
|
|
63
|
+
if root&.webview&.wire_id
|
|
64
|
+
@screens = [root] if @screens.size > 1
|
|
65
|
+
navigate_root_body(root, url, spec)
|
|
66
|
+
else
|
|
67
|
+
@screens.clear
|
|
68
|
+
push_webview(url, root: true, spec: spec)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Swap only the mounted shell body. A fresh WebView gets fresh client-side
|
|
73
|
+
# listeners while the AppBar, drawer, and bottom chrome stay mounted.
|
|
74
|
+
def navigate_root_body(screen, url, spec)
|
|
75
|
+
screen.url = url
|
|
76
|
+
screen.webview = build_webview(screen)
|
|
77
|
+
screen.loading = build_loading_state
|
|
78
|
+
screen.body = build_webview_body(screen)
|
|
79
|
+
apply_spec_chrome(screen, spec)
|
|
80
|
+
sync_drawer_selection(screen)
|
|
81
|
+
update_screen_controls(screen)
|
|
82
|
+
sync_root_navigation_bar_selection
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Apply declared chrome onto the reused shell in place. A title-only hint
|
|
86
|
+
# just repaints the AppBar text; richer chrome replaces the AppBar control.
|
|
87
|
+
def apply_spec_chrome(screen, spec)
|
|
88
|
+
new_spec = appbar_spec_from(spec)
|
|
89
|
+
return unless new_spec
|
|
90
|
+
|
|
91
|
+
if new_spec[:leading].nil? && Array(new_spec[:actions]).empty? && new_spec[:props].empty? && new_spec[:title_props].empty?
|
|
92
|
+
update_screen_title(screen, new_spec[:title].to_s)
|
|
93
|
+
return
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
screen.appbar_spec = new_spec
|
|
97
|
+
screen.appbar_signature = nil
|
|
98
|
+
screen.title_text = build_title_text(screen)
|
|
99
|
+
update_screen_appbar(screen)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Repaint just the AppBar's title text in place (no AppBar rebuild).
|
|
103
|
+
def update_screen_title(screen, title)
|
|
104
|
+
return if title.empty?
|
|
105
|
+
|
|
106
|
+
text = screen.title_text
|
|
107
|
+
return unless text
|
|
108
|
+
|
|
109
|
+
if text.wire_id
|
|
110
|
+
return if text.props["value"] == title
|
|
111
|
+
|
|
112
|
+
@page.update(text, value: title)
|
|
113
|
+
else
|
|
114
|
+
text.props["value"] = title
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Replace the current top screen with a new one (no extra back step).
|
|
119
|
+
def replace_webview(url, spec: nil)
|
|
120
|
+
url = absolute_url(url)
|
|
121
|
+
return if url.empty?
|
|
122
|
+
|
|
123
|
+
root = @screens.size <= 1
|
|
124
|
+
@screens.pop unless @screens.empty?
|
|
125
|
+
push_webview(url, root: root, spec: spec)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# --- HTML-declared native chrome ---------------------------------------
|
|
129
|
+
|
|
130
|
+
# The page declared a `ruflet-appbar`: give the reporting screen a native
|
|
131
|
+
# AppBar with that title and any action buttons (each navigates on tap).
|
|
132
|
+
def apply_appbar(screen, spec)
|
|
133
|
+
return unless screen
|
|
134
|
+
return clear_appbar(screen) if chrome_absent?(spec)
|
|
135
|
+
|
|
136
|
+
spec = bottomnav_appbar_spec(screen.url, spec) if screen.equal?(@screens.first) && bottomnav_item_for(screen.url)
|
|
137
|
+
remember_appbar_spec(screen.url, spec)
|
|
138
|
+
signature = JSON.generate(canonicalize(spec))
|
|
139
|
+
return if screen.appbar_signature == signature
|
|
140
|
+
|
|
141
|
+
screen.appbar_spec = appbar_spec_from(spec) || { title: spec["title"].to_s, leading: nil, actions: [], props: {}, title_props: {} }
|
|
142
|
+
screen.title_text = build_title_text(screen)
|
|
143
|
+
screen.appbar_signature = signature
|
|
144
|
+
update_screen_appbar(screen)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# The page declared a `ruflet-bottomnav`: build a native NavigationBar on
|
|
148
|
+
# the root view. Selecting a destination switches to that URL as a fresh
|
|
149
|
+
# root (tab semantics).
|
|
150
|
+
def apply_bottomnav(spec)
|
|
151
|
+
return if chrome_absent?(spec)
|
|
152
|
+
|
|
153
|
+
@bottomnav_spec = spec
|
|
154
|
+
return unless buildable_bottomnav?(spec)
|
|
155
|
+
|
|
156
|
+
# Like the drawer, the bottom bar is a persistent client-owned control:
|
|
157
|
+
# the signature ignores selection, so re-reporting it on each page load
|
|
158
|
+
# only re-points the selected tab instead of rebuilding (and churning)
|
|
159
|
+
# the control.
|
|
160
|
+
signature = structural_signature(spec)
|
|
161
|
+
if @bottomnav_signature == signature
|
|
162
|
+
sync_root_navigation_bar_selection
|
|
163
|
+
return
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
@navigation_bar = build_bottomnav(spec)
|
|
167
|
+
@bottomnav_signature = signature
|
|
168
|
+
update_root_navigation_bar
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# The page declared a `ruflet-drawer`: build a native NavigationDrawer on
|
|
172
|
+
# the reporting screen. Selecting a destination closes the drawer first,
|
|
173
|
+
# then applies that item's requested navigation mode.
|
|
174
|
+
def apply_drawer(screen, spec)
|
|
175
|
+
return unless screen
|
|
176
|
+
return clear_drawer(screen) if chrome_absent?(spec)
|
|
177
|
+
|
|
178
|
+
@drawer_spec = spec
|
|
179
|
+
items = Array(spec["items"])
|
|
180
|
+
urls = items.map { |item| absolute_url(item["url"]) }
|
|
181
|
+
modes = items.map { |item| (item["action"] || item["mode"] || "root").to_s }
|
|
182
|
+
static_controls = drawer_static_controls(spec)
|
|
183
|
+
item_offset = static_controls.length
|
|
184
|
+
selected_index = drawer_selected_index(items)
|
|
185
|
+
destinations = items.each_with_index.filter_map do |item, index|
|
|
186
|
+
build_drawer_destination(item, selected: index == selected_index, on_click: lambda do |_event|
|
|
187
|
+
select_drawer_item(screen, items, urls, modes, index)
|
|
188
|
+
end)
|
|
189
|
+
end
|
|
190
|
+
return if destinations.empty?
|
|
191
|
+
|
|
192
|
+
# The drawer is a persistent, client-owned control. Each page re-reports it
|
|
193
|
+
# on load; when the structure is unchanged we keep the same control and
|
|
194
|
+
# only re-point its selection, so its open/close state survives navigation.
|
|
195
|
+
signature = structural_signature(spec)
|
|
196
|
+
if screen.drawer_signature == signature
|
|
197
|
+
sync_drawer_selection(screen)
|
|
198
|
+
if @drawer_open_requested
|
|
199
|
+
@drawer_open_requested = false
|
|
200
|
+
show_drawer
|
|
201
|
+
end
|
|
202
|
+
return
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
drawer = navigation_drawer(
|
|
206
|
+
static_controls + destinations,
|
|
207
|
+
**ruflet_props_for(:navigation_drawer, spec),
|
|
208
|
+
selected_index: item_offset + selected_index,
|
|
209
|
+
on_change: lambda do |event|
|
|
210
|
+
index = navigation_index(event) - item_offset
|
|
211
|
+
next if index.negative? || index >= items.length
|
|
212
|
+
|
|
213
|
+
select_drawer_item(screen, items, urls, modes, index)
|
|
214
|
+
end
|
|
215
|
+
)
|
|
216
|
+
screen.drawer_signature = signature
|
|
217
|
+
update_screen_drawer(screen, drawer: drawer)
|
|
218
|
+
update_screen_appbar(screen) if screen.appbar_spec
|
|
219
|
+
if @drawer_open_requested
|
|
220
|
+
@drawer_open_requested = false
|
|
221
|
+
show_drawer
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# The page declared a `ruflet-rail`: desktop/tablet native navigation.
|
|
226
|
+
# Unlike drawer/bottom nav, NavigationRail is a control, so the WebView
|
|
227
|
+
# body is wrapped in a Row with the rail on the left.
|
|
228
|
+
def apply_rail(screen, spec)
|
|
229
|
+
return unless screen
|
|
230
|
+
return clear_rail(screen) if chrome_absent?(spec)
|
|
231
|
+
|
|
232
|
+
items = Array(spec["items"])
|
|
233
|
+
destinations = items.filter_map { |item| build_rail_destination(item) }
|
|
234
|
+
return if destinations.length < 2
|
|
235
|
+
|
|
236
|
+
urls = items.map { |item| absolute_url(item["url"]) }
|
|
237
|
+
|
|
238
|
+
# Persistent client-owned control: same structure → only re-point the
|
|
239
|
+
# selection, never rebuild.
|
|
240
|
+
signature = structural_signature(spec)
|
|
241
|
+
if screen.rail_signature == signature
|
|
242
|
+
sync_rail_selection(screen, urls)
|
|
243
|
+
return
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
modes = items.map { |item| (item["action"] || item["mode"] || "root").to_s }
|
|
247
|
+
rail_args = {
|
|
248
|
+
destinations: destinations,
|
|
249
|
+
selected_index: items.index { |item| item["selected"] } || 0,
|
|
250
|
+
**ruflet_props_for(:navigation_rail, spec),
|
|
251
|
+
label_type: spec["label_type"] || spec["labelType"] || "all",
|
|
252
|
+
on_change: lambda do |event|
|
|
253
|
+
index = navigation_index(event)
|
|
254
|
+
target = urls[index].to_s
|
|
255
|
+
next if target.empty?
|
|
256
|
+
|
|
257
|
+
mode = modes[index]
|
|
258
|
+
spec = mode == "root" ? { "title" => items[index]["label"].to_s } : nil
|
|
259
|
+
navigate_screen(target, mode, spec)
|
|
260
|
+
end
|
|
261
|
+
}
|
|
262
|
+
rail_args[:extended] = !!spec["extended"] unless spec["extended"].nil?
|
|
263
|
+
rail_args[:min_width] = spec["min_width"].to_i if spec["min_width"]
|
|
264
|
+
rail_args[:min_extended_width] = spec["min_extended_width"].to_i if spec["min_extended_width"]
|
|
265
|
+
screen.rail = navigation_rail(**rail_args)
|
|
266
|
+
screen.rail_signature = signature
|
|
267
|
+
update_screen_controls(screen)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Signature of a chrome spec's STRUCTURE, ignoring which item is currently
|
|
271
|
+
# selected. Selection is client-owned state we patch via `selected_index`,
|
|
272
|
+
# so a navigation that only moves the highlight keeps the same controls.
|
|
273
|
+
def structural_signature(spec)
|
|
274
|
+
JSON.generate(canonicalize(strip_selection(spec)))
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def chrome_absent?(spec)
|
|
278
|
+
spec.is_a?(Hash) && spec["absent"] == true
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def clear_appbar(screen)
|
|
282
|
+
screen.appbar_spec = nil
|
|
283
|
+
screen.appbar_signature = nil
|
|
284
|
+
screen.title_text = build_title_text(screen)
|
|
285
|
+
update_screen_appbar(screen)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def clear_drawer(screen)
|
|
289
|
+
@drawer_spec = nil
|
|
290
|
+
@drawer_open_requested = false
|
|
291
|
+
screen.drawer_signature = nil
|
|
292
|
+
update_screen_drawer(screen, drawer: nil, end_drawer: nil)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def clear_rail(screen)
|
|
296
|
+
screen.rail = nil
|
|
297
|
+
screen.rail_signature = nil
|
|
298
|
+
update_screen_controls(screen)
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def strip_selection(value)
|
|
302
|
+
case value
|
|
303
|
+
when Hash
|
|
304
|
+
value.each_with_object({}) do |(key, nested), out|
|
|
305
|
+
next if %w[selected selected_index].include?(key.to_s)
|
|
306
|
+
|
|
307
|
+
out[key] = strip_selection(nested)
|
|
308
|
+
end
|
|
309
|
+
when Array
|
|
310
|
+
value.map { |item| strip_selection(item) }
|
|
311
|
+
else
|
|
312
|
+
value
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def canonicalize(value)
|
|
317
|
+
case value
|
|
318
|
+
when Hash
|
|
319
|
+
value.keys.map(&:to_s).sort.each_with_object({}) do |key, out|
|
|
320
|
+
original_key = value.key?(key) ? key : key.to_sym
|
|
321
|
+
out[key] = canonicalize(value[original_key])
|
|
322
|
+
end
|
|
323
|
+
when Array
|
|
324
|
+
value.map { |item| canonicalize(item) }
|
|
325
|
+
else
|
|
326
|
+
value
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def build_destination(item)
|
|
331
|
+
icon = item["icon"].to_s
|
|
332
|
+
return nil if icon.empty?
|
|
333
|
+
|
|
334
|
+
navigation_bar_destination(icon: icon, label: item["label"].to_s,
|
|
335
|
+
**ruflet_props_for(:navigation_bar_destination, item))
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def buildable_bottomnav?(spec)
|
|
339
|
+
Array(spec["items"]).filter_map { |item| build_destination(item) }.length >= 2
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def build_bottomnav(spec)
|
|
343
|
+
items = Array(spec["items"])
|
|
344
|
+
destinations = items.filter_map { |item| build_destination(item) }
|
|
345
|
+
urls = items.map { |item| absolute_url(item["url"]) }
|
|
346
|
+
|
|
347
|
+
navigation_bar(
|
|
348
|
+
**ruflet_props_for(:navigation_bar, spec),
|
|
349
|
+
destinations: destinations,
|
|
350
|
+
selected_index: bottomnav_selected_index(items),
|
|
351
|
+
on_change: lambda do |event|
|
|
352
|
+
index = navigation_index(event)
|
|
353
|
+
target = urls[index].to_s
|
|
354
|
+
next if target.empty? || same_url?(target, current_url)
|
|
355
|
+
|
|
356
|
+
# Route through navigate_screen (not go_webview directly) so a sheet
|
|
357
|
+
# opened/closed before this tap is torn down and the view re-rendered
|
|
358
|
+
# in full. The tab label is carried so the AppBar shows the
|
|
359
|
+
# destination's name immediately.
|
|
360
|
+
navigate_screen(target, "root", { "title" => items[index]["label"].to_s })
|
|
361
|
+
end
|
|
362
|
+
)
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def bottomnav_selected_index(items)
|
|
366
|
+
urls = items.map { |item| absolute_url(item["url"]) }
|
|
367
|
+
urls.index { |candidate| same_url?(candidate, current_url) } ||
|
|
368
|
+
items.index { |item| item["selected"] } || 0
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def bottomnav_appbar_spec(url, spec)
|
|
372
|
+
declared = appbar_spec_for_url(url)
|
|
373
|
+
current = @screens.first&.appbar_spec
|
|
374
|
+
label = bottomnav_item_for(url)&.fetch("label", nil).to_s
|
|
375
|
+
title =
|
|
376
|
+
if declared && !declared["title"].to_s.empty?
|
|
377
|
+
declared["title"].to_s
|
|
378
|
+
elsif current && !current[:title].to_s.empty?
|
|
379
|
+
current[:title].to_s
|
|
380
|
+
elsif spec.is_a?(Hash) && !spec["title"].to_s.empty?
|
|
381
|
+
spec["title"].to_s
|
|
382
|
+
else
|
|
383
|
+
label
|
|
384
|
+
end
|
|
385
|
+
actions =
|
|
386
|
+
if declared
|
|
387
|
+
declared["actions"]
|
|
388
|
+
else
|
|
389
|
+
spec.is_a?(Hash) ? spec["actions"] : nil
|
|
390
|
+
end
|
|
391
|
+
{ "title" => title, "leading" => { "action" => "drawer" }, "actions" => actions }.compact
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def bottomnav_item_for(url)
|
|
395
|
+
items = Array(@bottomnav_spec && @bottomnav_spec["items"])
|
|
396
|
+
items.find { |item| same_url?(absolute_url(item["url"]), url) }
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def remember_appbar_spec(url, spec)
|
|
400
|
+
return unless spec.is_a?(Hash)
|
|
401
|
+
|
|
402
|
+
key = appbar_spec_key(url)
|
|
403
|
+
@appbar_specs_by_url[key] = canonicalize(spec) if key
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def appbar_spec_for_url(url)
|
|
407
|
+
key = appbar_spec_key(url)
|
|
408
|
+
key && @appbar_specs_by_url[key]
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def appbar_spec_key(url)
|
|
412
|
+
uri = URI.parse(absolute_url(url))
|
|
413
|
+
path = uri.path.to_s.empty? ? "/" : uri.path.to_s
|
|
414
|
+
"#{uri.scheme}://#{uri.host}:#{uri.port}#{path}"
|
|
415
|
+
rescue URI::InvalidURIError
|
|
416
|
+
nil
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def sync_root_navigation_bar_selection
|
|
420
|
+
return unless @navigation_bar&.wire_id && @bottomnav_spec
|
|
421
|
+
|
|
422
|
+
index = bottomnav_selected_index(Array(@bottomnav_spec["items"]))
|
|
423
|
+
return if @navigation_bar.props["selected_index"] == index
|
|
424
|
+
|
|
425
|
+
@navigation_bar.props["selected_index"] = index
|
|
426
|
+
@page.update(@navigation_bar, selected_index: index)
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def build_drawer_destination(item, selected: false, on_click: nil)
|
|
430
|
+
icon = item["icon"].to_s
|
|
431
|
+
return nil if icon.empty?
|
|
432
|
+
|
|
433
|
+
list_tile(
|
|
434
|
+
**ruflet_props_for(:list_tile, item),
|
|
435
|
+
leading: icon(icon, **nested_ruflet_props(item, "icon_props", control: :icon)),
|
|
436
|
+
title: text(item["label"].to_s, **nested_ruflet_props(item, "label_props", "title_props", control: :text)),
|
|
437
|
+
selected: selected,
|
|
438
|
+
on_click: on_click
|
|
439
|
+
)
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def drawer_static_controls(spec)
|
|
443
|
+
header = spec["header"]
|
|
444
|
+
return [] unless header.is_a?(Hash)
|
|
445
|
+
|
|
446
|
+
title = header["title"].to_s
|
|
447
|
+
subtitle = header["subtitle"].to_s
|
|
448
|
+
avatar = header["avatar"].to_s
|
|
449
|
+
avatar = title[0].to_s.upcase if avatar.empty? && !title.empty?
|
|
450
|
+
[
|
|
451
|
+
container(
|
|
452
|
+
padding: { left: 18, right: 18, top: 24, bottom: 18 },
|
|
453
|
+
content: row(
|
|
454
|
+
controls: [
|
|
455
|
+
circle_avatar(
|
|
456
|
+
text(avatar, color: "#047857", weight: "bold", size: 18),
|
|
457
|
+
bgcolor: "#D1FAE5",
|
|
458
|
+
radius: 28
|
|
459
|
+
),
|
|
460
|
+
column(
|
|
461
|
+
controls: [
|
|
462
|
+
text(title, weight: "bold", color: "#0F172A", size: 16, max_lines: 1, no_wrap: true),
|
|
463
|
+
text(subtitle, color: "#64748B", size: 12, max_lines: 1, no_wrap: true)
|
|
464
|
+
],
|
|
465
|
+
spacing: 2,
|
|
466
|
+
tight: true,
|
|
467
|
+
expand: true
|
|
468
|
+
)
|
|
469
|
+
],
|
|
470
|
+
spacing: 12,
|
|
471
|
+
vertical_alignment: "center"
|
|
472
|
+
)
|
|
473
|
+
),
|
|
474
|
+
divider(height: 1, color: "#E2E8F0")
|
|
475
|
+
]
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
def select_drawer_item(screen, items, urls, modes, index)
|
|
479
|
+
close_drawer(screen)
|
|
480
|
+
target = urls[index].to_s
|
|
481
|
+
return if target.empty?
|
|
482
|
+
|
|
483
|
+
if same_url?(target, current_url)
|
|
484
|
+
sync_drawer_selection(screen)
|
|
485
|
+
return
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
mode = modes[index]
|
|
489
|
+
if %w[delete post patch put].include?(mode)
|
|
490
|
+
submit_webview_request(target, mode)
|
|
491
|
+
return
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
# Only a tab (root) switch carries a title hint to repaint the AppBar
|
|
495
|
+
# title in place. A push keeps its own declared chrome (notably the
|
|
496
|
+
# back button), so we must NOT force a title-only AppBar on it.
|
|
497
|
+
spec = mode == "root" ? { "title" => items[index]["label"].to_s } : nil
|
|
498
|
+
navigate_screen(target, mode, spec)
|
|
499
|
+
close_drawer(@screens.last)
|
|
500
|
+
sync_drawer_selection(screen)
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
def build_rail_destination(item)
|
|
504
|
+
icon = item["icon"].to_s
|
|
505
|
+
return nil if icon.empty?
|
|
506
|
+
|
|
507
|
+
navigation_rail_destination(icon: icon, label: item["label"].to_s,
|
|
508
|
+
**ruflet_props_for(:navigation_rail_destination, item))
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
def navigation_index(event)
|
|
512
|
+
data = event.respond_to?(:data) ? event.data : event
|
|
513
|
+
return (data["selected_index"] || data["value"] || data["i"] || 0).to_i if data.is_a?(Hash)
|
|
514
|
+
|
|
515
|
+
data.to_i
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
# Keep the drawer's highlighted destination in sync with the route we are
|
|
519
|
+
# actually on. Selecting a drawer item (or tapping a tab) leaves the drawer
|
|
520
|
+
# marking that item, and the screen's WebView is not re-reported on a tab
|
|
521
|
+
# switch or a back navigation — so without this the drawer keeps
|
|
522
|
+
# highlighting a route we already left. Re-point the selection to the item
|
|
523
|
+
# matching the current URL (or the declared default). Called after every
|
|
524
|
+
# root navigation and on back.
|
|
525
|
+
def sync_drawer_selection(screen)
|
|
526
|
+
drawer = screen&.drawer
|
|
527
|
+
return unless drawer&.wire_id
|
|
528
|
+
|
|
529
|
+
index = drawer_selected_index(Array(@drawer_spec && @drawer_spec["items"]))
|
|
530
|
+
item_offset = drawer_static_controls(@drawer_spec || {}).length
|
|
531
|
+
selected_control_index = item_offset + index
|
|
532
|
+
if drawer.props["selected_index"] != selected_control_index
|
|
533
|
+
drawer.props["selected_index"] = selected_control_index
|
|
534
|
+
@page.update(drawer, selected_index: selected_control_index)
|
|
535
|
+
end
|
|
536
|
+
sync_drawer_tile_selection(drawer, index, item_offset)
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
def sync_drawer_tile_selection(drawer, selected_index, item_offset = 0)
|
|
540
|
+
drawer.children.each_with_index do |control, control_index|
|
|
541
|
+
next if control_index < item_offset
|
|
542
|
+
next unless control.respond_to?(:props)
|
|
543
|
+
|
|
544
|
+
selected = (control_index - item_offset) == selected_index
|
|
545
|
+
next if control.props["selected"] == selected
|
|
546
|
+
|
|
547
|
+
control.props["selected"] = selected
|
|
548
|
+
@page.update(control, selected: selected) if control.wire_id
|
|
549
|
+
end
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
def sync_rail_selection(screen, urls)
|
|
553
|
+
rail = screen&.rail
|
|
554
|
+
return unless rail&.wire_id
|
|
555
|
+
|
|
556
|
+
index = urls.index { |candidate| same_url?(candidate, screen.url) } || 0
|
|
557
|
+
return if rail.props["selected_index"] == index
|
|
558
|
+
|
|
559
|
+
rail.props["selected_index"] = index
|
|
560
|
+
@page.update(rail, selected_index: index)
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
def drawer_selected_index(items)
|
|
564
|
+
urls = items.map { |item| absolute_url(item["url"]) }
|
|
565
|
+
urls.index { |candidate| same_url?(candidate, current_url) } ||
|
|
566
|
+
items.index { |item| item["selected"] } || 0
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
def show_drawer
|
|
570
|
+
screen = @screens.last
|
|
571
|
+
return unless screen&.view&.wire_id
|
|
572
|
+
unless screen.drawer || screen.view.props["drawer"]
|
|
573
|
+
if @drawer_spec
|
|
574
|
+
@drawer_open_requested = true
|
|
575
|
+
apply_drawer(screen, @drawer_spec)
|
|
576
|
+
return
|
|
577
|
+
end
|
|
578
|
+
@drawer_open_requested = true
|
|
579
|
+
return
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
invoke_drawer_when_ready(screen, "show_drawer")
|
|
583
|
+
rescue StandardError
|
|
584
|
+
nil
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
# Close the native drawer on the view that owns it (and the current top
|
|
588
|
+
# screen, if different). The drawer is a persistent, client-owned control,
|
|
589
|
+
# so a single ordered invoke is enough — no rebuild races it anymore.
|
|
590
|
+
def close_drawer(screen = nil)
|
|
591
|
+
@drawer_open_requested = false
|
|
592
|
+
targets = [screen, @screens.last].compact.uniq
|
|
593
|
+
targets.each { |target| invoke_close_drawer(target) }
|
|
594
|
+
rescue StandardError
|
|
595
|
+
nil
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
def show_end_drawer
|
|
599
|
+
screen = @screens.last
|
|
600
|
+
return unless screen&.view&.wire_id
|
|
601
|
+
|
|
602
|
+
invoke_drawer_when_ready(screen, "show_end_drawer")
|
|
603
|
+
rescue StandardError
|
|
604
|
+
nil
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
# The drawer is a persistent control mounted on the View well before the
|
|
608
|
+
# user can open it, so a single ordered invoke opens it. (The previous
|
|
609
|
+
# sleep/retry loop relied on real background threads; on the embedded
|
|
610
|
+
# runtime Thread runs inline, so it only blocked the UI without deferring.)
|
|
611
|
+
def invoke_drawer_when_ready(screen, method_name)
|
|
612
|
+
return unless screen&.view&.wire_id
|
|
613
|
+
|
|
614
|
+
@page.invoke(screen.view, method_name)
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
def invoke_close_drawer(screen)
|
|
618
|
+
return unless screen&.view&.wire_id
|
|
619
|
+
|
|
620
|
+
@page.invoke(screen.view, "close_drawer")
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
def absolute_url(url)
|
|
624
|
+
url = url.to_s
|
|
625
|
+
return "" if url.empty?
|
|
626
|
+
return url if URI.parse(url).absolute?
|
|
627
|
+
|
|
628
|
+
URI.join(current_url, url).to_s
|
|
629
|
+
rescue URI::InvalidURIError
|
|
630
|
+
url
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
def current_url
|
|
634
|
+
@screens.last&.url || @start_url
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
def message_of(event)
|
|
638
|
+
data = event.respond_to?(:data) ? event.data : event
|
|
639
|
+
return data["message"] || data[:message] if data.is_a?(Hash)
|
|
640
|
+
|
|
641
|
+
data
|
|
642
|
+
end
|
|
643
|
+
end
|
|
644
|
+
end
|
|
645
|
+
end
|