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.
@@ -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