ruflet_rails 0.0.9 → 0.0.11

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.
@@ -113,129 +113,25 @@ module Ruflet
113
113
  File.join("app", "views", "ruflet", "components", names[:plural], "#{names[:singular]}_form.rb")
114
114
  end
115
115
 
116
- def scaffold_view_path(model_name)
117
- names = model_names(model_name)
118
-
119
- File.join("app", "views", "ruflet", "#{names[:plural]}_view.rb")
120
- end
121
-
122
116
  def scaffold_component_path(model_name)
123
117
  names = model_names(model_name)
124
118
 
125
119
  File.join("app", "views", "ruflet", "components", names[:plural], "#{names[:singular]}_component.rb")
126
120
  end
127
121
 
128
- def scaffold_view_template(model_name:, attributes: [])
129
- names = model_names(model_name)
130
- model_class = names[:class_name]
131
- view_class = "#{model_class}View"
132
- component_class = "#{model_class}Component"
133
- title = names[:title]
134
- attrs = normalized_form_attributes(attributes)
135
- resource_fields = scaffold_resource_fields(attrs)
136
- display_fields = scaffold_display_fields(attrs)
137
- display_value_cases = scaffold_display_value_cases(attrs)
138
-
139
- template = <<~RUBY
140
- # frozen_string_literal: true
141
-
142
- require "ruflet_rails"
143
- require_relative "components/#{names[:plural]}/#{names[:singular]}_component"
144
-
145
- class #{view_class} < Ruflet::Rails::ResourceView
146
- route #{("/" + names[:plural]).inspect}
147
-
148
- def render
149
- page.title = resource_title
150
- render_index
151
- end
152
-
153
- private
154
-
155
- def model_class
156
- #{model_class}
157
- end
158
-
159
- def resource_title
160
- #{title.inspect}
161
- end
162
-
163
- def singular_title
164
- model_class.model_name.human.titleize
165
- end
166
-
167
- def records
168
- scope = model_class.respond_to?(:limit) ? model_class.limit(50) : model_class.all
169
- scope.respond_to?(:limit) ? scope.limit(50) : scope.to_a.first(50)
170
- end
171
-
172
- def render_index
173
- page.views = []
174
- page.add(component.render)
175
- end
176
-
177
- def render_show(record)
178
- page.views = []
179
- page.add(component.show(record))
180
- page.update
181
- end
182
-
183
- def component
184
- @component ||= #{component_class}.new(page, controller: self)
185
- end
186
-
187
- def show_record(record)
188
- render_show(record)
189
- end
190
-
191
- def save_record(record, attributes, dialog)
192
- if record.update(attributes)
193
- close_dialog(dialog)
194
- render_index
195
- show_snackbar("\#{singular_title} saved")
196
- else
197
- show_errors(record)
198
- end
199
- end
200
-
201
- def destroy_record(record, dialog)
202
- record.destroy!
203
- close_dialog(dialog)
204
- render_index
205
- show_snackbar("\#{singular_title} deleted")
206
- rescue StandardError => e
207
- show_snackbar(e.message)
208
- end
209
-
210
- def resource_fields
211
- #{resource_fields}
212
- end
213
-
214
- def display_fields
215
- #{display_fields}
216
- end
217
-
218
- def display_value(record, field)
219
- case field
220
- __DISPLAY_VALUE_CASES__
221
- else
222
- record.public_send(field).to_s
223
- end
224
- end
225
-
226
- def primary_label(record)
227
- field = display_fields.first
228
- field ? display_value(record, field) : "##\#{record_id(record)}"
229
- end
230
-
231
- def secondary_label(record)
232
- field = display_fields[1]
233
- field ? display_value(record, field) : nil
234
- end
122
+ # A `case` with no `when` clause is a syntax error, so models without
123
+ # date/time attributes get the plain fallback body instead.
124
+ def scaffold_display_value_body(display_value_cases, indent)
125
+ return "#{indent}record.public_send(field).to_s" if display_value_cases.to_s.strip.empty?
235
126
 
127
+ reindented_cases = display_value_cases.gsub(/^ /, indent)
128
+ <<~RUBY.chomp.gsub(/^/, indent).gsub(/^#{Regexp.escape(indent)}__CASES__$/, reindented_cases)
129
+ case field
130
+ __CASES__
131
+ else
132
+ record.public_send(field).to_s
236
133
  end
237
134
  RUBY
238
- template.gsub(/^[ \t]*__DISPLAY_VALUE_CASES__$/, display_value_cases)
239
135
  end
240
136
 
241
137
  def scaffold_component_template(model_name:, attributes: [])
@@ -253,6 +149,15 @@ module Ruflet
253
149
  require "date"
254
150
  require "ruflet_rails"
255
151
 
152
+ # The model (#{model_class}) is inferred from the class name and the route
153
+ # ("/#{names[:plural]}") is declared in config/routes.rb. This file is YOURS:
154
+ # the whole CRUD UI (index table, detail screen, create/edit form) and the
155
+ # database calls (#{model_class}#update, #destroy!, .new) are explicit below
156
+ # so you can change the UI or logic however you like. The base class only
157
+ # provides reusable helpers: record loading, field inference, dialog
158
+ # open/close, the date/time picker value helpers, and refresh.
159
+ #
160
+ # The same component renders on web and on mobile/desktop.
256
161
  class #{component_class} < Ruflet::Rails::ResourceComponent
257
162
  def render
258
163
  safe_area(
@@ -401,6 +306,7 @@ module Ruflet
401
306
  width: dialog_width,
402
307
  content: column(
403
308
  spacing: 8,
309
+ horizontal_alignment: "stretch",
404
310
  children: [
405
311
  #{control_list}
406
312
  ]
@@ -409,7 +315,14 @@ module Ruflet
409
315
  actions: [
410
316
  text_button(content: text("Cancel"), on_click: ->(_event) { close_dialog(dialog) }),
411
317
  filled_button(content: text("Save"), on_click: ->(_event) {
412
- save_record(record, attributes.call, dialog)
318
+ # Persist with the model (this is your code — change it freely).
319
+ if record.update(attributes.call)
320
+ close_dialog(dialog)
321
+ refresh
322
+ show_snackbar("\#{singular_title} saved")
323
+ else
324
+ show_errors(record)
325
+ end
413
326
  })
414
327
  ],
415
328
  actions_alignment: "end"
@@ -426,7 +339,13 @@ module Ruflet
426
339
  content: text("Permanently remove \#{singular_title} #\#{record_id(record)}?", no_wrap: false),
427
340
  actions: [
428
341
  text_button(content: text("Cancel"), on_click: ->(_event) { close_dialog(dialog) }),
429
- filled_button(content: text("Delete"), on_click: ->(_event) { destroy_record(record, dialog) })
342
+ filled_button(content: text("Delete"), on_click: ->(_event) {
343
+ # Destroy with the model (this is your code — change it freely).
344
+ record.destroy!
345
+ close_dialog(dialog)
346
+ refresh
347
+ show_snackbar("\#{singular_title} deleted")
348
+ })
430
349
  ],
431
350
  actions_alignment: "end"
432
351
  )
@@ -512,7 +431,7 @@ module Ruflet
512
431
  help_text: #{label.inspect},
513
432
  on_change: ->(_event) do
514
433
  close_dialogs(#{control})
515
- page.update(#{display_control}, value: date_display_value(#{control}.props["value"]))
434
+ page.update(#{display_control}, value: date_display_value(#{control}.value))
516
435
  end
517
436
  )
518
437
  #{control}_field = column(
@@ -539,7 +458,7 @@ module Ruflet
539
458
  help_text: #{label.inspect},
540
459
  on_change: ->(_event) do
541
460
  close_dialogs(#{control})
542
- page.update(#{display_control}, value: time_display_value(#{control}.props["value"]))
461
+ page.update(#{display_control}, value: time_display_value(#{control}.value))
543
462
  end
544
463
  )
545
464
  #{control}_field = column(
@@ -569,7 +488,7 @@ module Ruflet
569
488
  close_dialogs(#{control})
570
489
  page.update(
571
490
  #{display_control},
572
- value: date_range_display_value(#{control}.props["start_value"], #{control}.props["end_value"])
491
+ value: date_range_display_value(#{control}.start_value, #{control}.end_value)
573
492
  )
574
493
  end
575
494
  )
@@ -603,13 +522,13 @@ module Ruflet
603
522
  value =
604
523
  case type
605
524
  when "boolean"
606
- "!!#{control}.props[\"value\"]"
525
+ "!!#{control}.value"
607
526
  when "date"
608
- "#{control}.props[\"value\"].to_s.split(\"T\", 2).first"
527
+ "#{control}.value.to_s.split(\"T\", 2).first"
609
528
  when "date_range", "daterange"
610
- "Range.new(Date.parse(#{control}.props[\"start_value\"].to_s), Date.parse(#{control}.props[\"end_value\"].to_s))"
529
+ "Range.new(Date.parse(#{control}.start_value.to_s), Date.parse(#{control}.end_value.to_s))"
611
530
  else
612
- "#{control}.props[\"value\"].to_s"
531
+ "#{control}.value.to_s"
613
532
  end
614
533
 
615
534
  "#{name.inspect} => #{value}"
@@ -648,7 +567,7 @@ module Ruflet
648
567
  spacing: 12,
649
568
  children: [
650
569
  text(title, size: 24, weight: "bold"),
651
- column(spacing: 8, children: ruflet_form_controls(fields)),
570
+ column(spacing: 8, horizontal_alignment: "stretch", children: ruflet_form_controls(fields)),
652
571
  row(
653
572
  spacing: 8,
654
573
  children: [
@@ -827,12 +746,12 @@ module Ruflet
827
746
  value
828
747
  end
829
748
 
830
- def build_args_for_platform(platform)
749
+ def build_args_for_platform(platform, ruflet_url: nil)
831
750
  normalized = normalize_build_platform(platform)
832
751
  return [] if normalized.to_s.empty?
833
752
 
834
753
  args = [normalized]
835
- args += ["--base-href", web_base_href] if normalized == "web"
754
+ args += ["--dart-define", "RUFLET_URL=#{ruflet_url}"] if normalized == "web" && ruflet_url.to_s.strip != ""
836
755
  args
837
756
  end
838
757
 
@@ -840,132 +759,57 @@ module Ruflet
840
759
  File.join("app", "views", "ruflet", "main.rb")
841
760
  end
842
761
 
843
- def route_snippet(entrypoint: default_entrypoint_path, mount_path: "/ws", helper: "app")
844
- %(match "#{mount_path}", to: Ruflet::Rails.#{helper}(Rails.root.join("#{entrypoint}")), via: :all)
845
- end
846
-
847
- def default_web_public_path
848
- "app"
849
- end
850
-
851
- def web_base_href(public_path = default_web_public_path)
852
- normalized = public_path.to_s.strip.gsub(%r{\A/+|/+\z}, "")
853
- normalized.empty? ? "/" : "/#{normalized}/"
854
- end
855
-
856
- def publish_web_build(root, public_path: default_web_public_path)
857
- publish_web_client(root, source: File.join(root, "build", "web"), public_path: public_path)
858
- end
859
-
860
- def publish_prebuilt_web_client(root, platform: host_desktop_platform, public_path: default_web_public_path)
861
- source = prebuilt_web_client_path(platform: platform)
862
- return false unless source
863
-
864
- publish_web_client(root, source: source, public_path: public_path)
865
- end
866
-
867
- def prebuilt_web_client_path(platform: host_desktop_platform)
868
- return nil if platform.to_s.empty?
869
-
870
- source = File.join(prebuilt_client_cache_root(platform: platform), "web")
871
- return nil unless Dir.exist?(source)
872
- return nil unless File.file?(File.join(source, "index.html"))
873
-
874
- source
875
- end
762
+ def initializer_template(entrypoint: default_entrypoint_path, ws_path: "/ws")
763
+ <<~RUBY
764
+ # frozen_string_literal: true
876
765
 
877
- def prebuilt_client_cache_root(platform: host_desktop_platform)
878
- require "ruflet/cli"
766
+ Ruflet::Rails.configure do |config|
767
+ # Ruflet app entry-point. Auto-mounts a WebSocket endpoint at ws_path —
768
+ # no explicit route needed in config/routes.rb.
769
+ config.app_file = Rails.root.join(#{entrypoint.inspect})
770
+
771
+ # URL path the WebSocket endpoint listens on (default: "/ws").
772
+ config.ws_path = #{ws_path.inspect}
773
+
774
+ # Base URL the Flutter client uses to reach this Rails app. Always
775
+ # required: it backs asset URLs (Ruflet::Rails.asset_url), the
776
+ # build-time RUFLET_URL define, and the desktop launcher. At runtime
777
+ # it can fall back to the connecting host, but a build has no request,
778
+ # so set it here. Point it at a LAN IP (not localhost) to test on a
779
+ # real device.
780
+ config.backend_url = ENV.fetch("RUFLET_BACKEND_URL") do
781
+ Rails.env.production? ? "https://example.com" : "http://localhost:3000"
782
+ end
879
783
 
880
- if Ruflet::CLI.respond_to?(:client_cache_root_for, true)
881
- Ruflet::CLI.send(:client_cache_root_for, platform)
882
- else
883
- File.join(Dir.home, ".ruflet", "client", Ruflet::VERSION, platform.to_s)
884
- end
784
+ # Directory the Flutter web build is served from. Defaults to
785
+ # Rails.root/build/web (where `rake ruflet:build[web]` outputs).
786
+ # Must stay OUTSIDE public/, or Rails would serve it statically and
787
+ # expose the app at a path no route declares.
788
+ # config.web_build_dir = Rails.root.join("build", "web")
789
+ end
790
+ RUBY
885
791
  end
886
792
 
887
- def publish_web_client(root, source:, public_path: default_web_public_path)
888
- return false unless Dir.exist?(source)
889
- return false unless File.file?(File.join(source, "index.html"))
890
-
891
- target = File.join(root, "public", public_path.to_s.gsub(%r{\A/+|/+\z}, ""))
892
- FileUtils.rm_rf(target)
893
- FileUtils.mkdir_p(File.dirname(target))
894
- FileUtils.cp_r(source, target)
895
- rewrite_web_base_href(target, public_path: public_path)
896
- inject_web_client_bootstrap(target)
897
- true
793
+ def initializer_path
794
+ File.join("config", "initializers", "ruflet.rb")
898
795
  end
899
796
 
900
- def rewrite_web_base_href(target, public_path:)
901
- index_path = File.join(target, "index.html")
902
- return unless File.file?(index_path)
903
-
904
- content = File.read(index_path)
905
- base_href = web_base_href(public_path)
906
- updated =
907
- if content.match?(%r{<base\s+href=["'][^"']*["']\s*/?>}i)
908
- content.sub(%r{<base\s+href=["'][^"']*["']\s*/?>}i, %(<base href="#{base_href}">))
909
- else
910
- content.sub(%r{<head([^>]*)>}i, %(<head\\1>\n <base href="#{base_href}">))
911
- end
912
- File.write(index_path, updated)
913
- end
914
-
915
- def inject_web_client_bootstrap(target)
916
- index_path = File.join(target, "index.html")
917
- return unless File.file?(index_path)
918
-
919
- content = File.read(index_path)
920
- return if content.include?('id="ruflet-rails-bootstrap"')
921
-
922
- script = <<~HTML
923
- <script id="ruflet-rails-bootstrap">
924
- if (window.location.search === "" && window.location.hash === "") {
925
- const rufletServerUrl = window.location.origin + "/";
926
- window.history.replaceState(
927
- null,
928
- document.title,
929
- window.location.pathname + "?url=" + encodeURIComponent(rufletServerUrl)
930
- );
931
- }
932
- </script>
933
- HTML
934
-
935
- updated =
936
- if content.include?('<script src="flutter_bootstrap.js"')
937
- content.sub(%r{<script src="flutter_bootstrap\.js"[^>]*></script>}i) { |match| "#{script} #{match}" }
938
- else
939
- content.sub(%r{</body>}i, "#{script}</body>")
940
- end
941
- File.write(index_path, updated)
797
+ # Kept for backward compatibility with apps that use manual mount.
798
+ def route_snippet(entrypoint: default_entrypoint_path, mount_path: "/ws", helper: "app")
799
+ %(match "#{mount_path}", to: Ruflet::Rails.#{helper}(Rails.root.join("#{entrypoint}")), via: :all)
942
800
  end
943
801
 
944
- def install_next_steps(target:, entrypoint:, client:, web_published:, mount_path: "/ws")
945
- web_path = default_web_public_path
802
+ def install_next_steps(target:, entrypoint:, client:, mount_path: "/ws")
946
803
  lines = [
947
804
  "Ruflet Rails installed.",
948
805
  "Generated entrypoint: #{entrypoint}",
949
806
  "Mounted websocket: #{mount_path}",
950
807
  "Next steps:",
951
808
  " 1. Start Rails: bin/rails server",
952
- " 2. Open the Ruflet web client: /#{web_path}/"
809
+ " 2. Connect your Ruflet app to ws://localhost:3000#{mount_path}"
953
810
  ]
954
811
 
955
- if web_published
956
- lines << "Web client copied to public/#{web_path}."
957
- elsif target.to_s == "ruflet" || %w[web all].include?(client.to_s)
958
- lines += [
959
- "Web client was not copied because no built/prebuilt web index.html was found.",
960
- "To download the prebuilt client from GitHub: bin/rails ruflet:update[web]",
961
- "To build the WASM web client yourself, install the ruflet CLI globally first:",
962
- " gem install ruflet",
963
- "Then build and copy build/web into public/#{web_path}:",
964
- " bin/rails ruflet:build[web]"
965
- ]
966
- end
967
-
968
- if %w[desktop all].include?(client.to_s)
812
+ if client.to_s == "desktop"
969
813
  lines += [
970
814
  "Desktop clients are server-driven and connect to this Rails app.",
971
815
  "Plain bin/dev, bin/rails server, and bin/rails s do not launch desktop.",
@@ -0,0 +1,253 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Ruflet
6
+ module Rails
7
+ # Hotwire Native-style driver for ruflet_rails.
8
+ #
9
+ # Your existing web app is the body, rendered in a WebView. Navigation works
10
+ # out of the box: a tiny JS bridge injected into each page intercepts link
11
+ # clicks and proposes them to native, so every visit becomes a NATIVE screen
12
+ # pushed onto the stack (with an automatic back button) — no per-link code.
13
+ # The native AppBar title tracks each page's <title>, and you declare special
14
+ # paths as data.
15
+ #
16
+ # Ruflet.run do |page|
17
+ # Ruflet::Rails.native_app(
18
+ # page,
19
+ # start_url: "https://myapp.com",
20
+ # title: "My App", # auto-updates from <title>
21
+ # actions: -> { [icon_button("search", on_click: ->(_e) { ... })] },
22
+ # navigation_bar: navigation_bar(destinations: [...]),
23
+ #
24
+ # # web content shown in a bottom sheet (auth, quick forms):
25
+ # modal: ["/sign_in", "/sign_up", %r{/new\z}],
26
+ #
27
+ # # optional: override a path with a fully native screen:
28
+ # native: { %r{\A/products/(\d+)\z} => ->(ctx) { product_screen(ctx.match[1]) } }
29
+ # )
30
+ # end
31
+ #
32
+ # Normal links just push a native webview screen (back returns). Paths listed
33
+ # in `modal:` open as a bottom sheet. Paths in `native:` render your own UI.
34
+ #
35
+ # The bridge talks to native over the webview's console channel
36
+ # (on_console_message), which works on iOS/Android/macOS. On platforms
37
+ # without a native webview the body degrades to a plain frame.
38
+ class NativeApp
39
+ # Injected into every page: report the title, and turn same-origin link
40
+ # clicks into native visit proposals (so they don't load in place).
41
+ BRIDGE_JS = <<~JS
42
+ (function () {
43
+ function report(kind, value) { console.log("ruflet:" + kind + ":" + value); }
44
+ report("title", document.title || "");
45
+ if (window.__rufletBridgeBound) return;
46
+ window.__rufletBridgeBound = true;
47
+ document.addEventListener("click", function (e) {
48
+ var a = e.target && e.target.closest ? e.target.closest("a[href]") : null;
49
+ if (!a || a.target === "_blank") return;
50
+ if (a.origin && a.origin !== location.origin) return; // external: leave it
51
+ e.preventDefault();
52
+ report("visit", a.href);
53
+ }, true);
54
+ })();
55
+ JS
56
+
57
+ VISIT_PREFIX = "ruflet:visit:"
58
+ TITLE_PREFIX = "ruflet:title:"
59
+
60
+ Screen = Struct.new(:kind, :url, :view, :webview, :title_text, keyword_init: true)
61
+ Context = Struct.new(:url, :path, :match, keyword_init: true)
62
+
63
+ def initialize(page, start_url:, title: nil, actions: nil, navigation_bar: nil,
64
+ bottom_appbar: nil, modal: [], native: {})
65
+ @page = page
66
+ @start_url = start_url.to_s
67
+ @title = title.to_s
68
+ @actions = actions
69
+ @navigation_bar = navigation_bar
70
+ @bottom_appbar = bottom_appbar
71
+ @modal_patterns = Array(modal).map { |p| compile_pattern(p) }
72
+ @native_rules = native.map { |pattern, builder| [compile_pattern(pattern), builder] }
73
+ @screens = []
74
+ @modal_sheet = nil
75
+ end
76
+
77
+ def self.bridge_js = BRIDGE_JS
78
+
79
+ def start
80
+ @page.on_view_pop = ->(_event) { pop }
81
+ push_webview(@start_url, root: true)
82
+ self
83
+ end
84
+
85
+ private
86
+
87
+ # --- Stack operations ---------------------------------------------------
88
+
89
+ def push_webview(url, root: false)
90
+ screen = Screen.new(kind: :webview, url: url.to_s)
91
+ screen.title_text = Ruflet::UI::ControlFactory.build(:text, value: @title)
92
+ screen.webview = build_webview(screen)
93
+ screen.view = build_view(screen, root: root)
94
+ @screens.push(screen)
95
+ flush
96
+ screen
97
+ end
98
+
99
+ def push_native(builder, ctx)
100
+ view = builder.call(ctx)
101
+ return unless view.is_a?(Ruflet::Control)
102
+
103
+ @screens.push(Screen.new(kind: :native, url: ctx.url, view: view))
104
+ flush
105
+ end
106
+
107
+ def pop
108
+ return if @screens.size <= 1
109
+
110
+ @screens.pop
111
+ flush
112
+ end
113
+
114
+ def flush
115
+ @page.views = @screens.map(&:view)
116
+ @page.update
117
+ end
118
+
119
+ # --- Webview screen + native chrome ------------------------------------
120
+
121
+ def build_webview(screen)
122
+ Ruflet::UI::ControlFactory.build(
123
+ :webview,
124
+ url: screen.url,
125
+ expand: true,
126
+ on_page_ended: ->(_event) { inject_bridge(screen) },
127
+ on_console_message: ->(event) { handle_message(screen, message_of(event)) }
128
+ )
129
+ end
130
+
131
+ def build_view(screen, root:)
132
+ args = { route: root ? "/" : "/screen", controls: [screen.webview], appbar: build_appbar(screen) }
133
+ if root
134
+ args[:navigation_bar] = @navigation_bar unless @navigation_bar.nil?
135
+ args[:bottom_appbar] = @bottom_appbar unless @bottom_appbar.nil?
136
+ end
137
+ Ruflet::UI::ControlFactory.build(:view, **args)
138
+ end
139
+
140
+ def build_appbar(screen)
141
+ args = { title: screen.title_text }
142
+ actions = resolve_actions
143
+ args[:actions] = actions if actions
144
+ Ruflet::UI::ControlFactory.build(:appbar, **args)
145
+ end
146
+
147
+ def resolve_actions
148
+ @actions.respond_to?(:call) ? @actions.call : @actions
149
+ end
150
+
151
+ # --- Bridge ------------------------------------------------------------
152
+
153
+ def inject_bridge(screen)
154
+ screen.webview.run_javascript(BRIDGE_JS) if screen.webview.respond_to?(:run_javascript)
155
+ rescue StandardError
156
+ nil
157
+ end
158
+
159
+ def handle_message(screen, message)
160
+ return if message.nil?
161
+
162
+ if message.start_with?(TITLE_PREFIX)
163
+ update_title(screen, message[TITLE_PREFIX.length..])
164
+ elsif message.start_with?(VISIT_PREFIX)
165
+ visit(message[VISIT_PREFIX.length..])
166
+ end
167
+ end
168
+
169
+ def update_title(screen, value)
170
+ return unless screen.title_text&.wire_id
171
+
172
+ @page.update(screen.title_text, value: value.to_s)
173
+ end
174
+
175
+ # A proposed visit: native screen > modal sheet > push a webview screen.
176
+ def visit(url)
177
+ path = path_of(url)
178
+ return if path.nil?
179
+
180
+ builder, match = match_native(path)
181
+ if builder
182
+ push_native(builder, Context.new(url: url, path: path, match: match))
183
+ elsif modal?(path)
184
+ present_modal(url)
185
+ else
186
+ push_webview(url)
187
+ end
188
+ end
189
+
190
+ # --- Modal (bottom sheet of web content) -------------------------------
191
+
192
+ def present_modal(url)
193
+ sheet_webview = Ruflet::UI::ControlFactory.build(:webview, url: url.to_s, expand: true)
194
+ @modal_sheet = Ruflet::UI::ControlFactory.build(
195
+ :bottomsheet,
196
+ open: true,
197
+ dismissible: true,
198
+ content: Ruflet::UI::ControlFactory.build(:container, height: 520, content: sheet_webview),
199
+ on_dismiss: ->(_event) { @modal_sheet = nil }
200
+ )
201
+ @page.bottom_sheet = @modal_sheet
202
+ @page.update
203
+ end
204
+
205
+ # --- Matching ----------------------------------------------------------
206
+
207
+ def match_native(path)
208
+ @native_rules.each do |pattern, builder|
209
+ if pattern.is_a?(Regexp)
210
+ m = pattern.match(path)
211
+ return [builder, m] if m
212
+ elsif pattern == path
213
+ return [builder, nil]
214
+ end
215
+ end
216
+ nil
217
+ end
218
+
219
+ def modal?(path)
220
+ @modal_patterns.any? do |pattern|
221
+ pattern.is_a?(Regexp) ? pattern.match?(path) : pattern == path
222
+ end
223
+ end
224
+
225
+ def compile_pattern(pattern)
226
+ return pattern if pattern.is_a?(Regexp)
227
+
228
+ value = pattern.to_s
229
+ value.start_with?("/") ? value : "/#{value}"
230
+ end
231
+
232
+ def path_of(url)
233
+ URI.parse(url.to_s).path
234
+ rescue URI::InvalidURIError
235
+ nil
236
+ end
237
+
238
+ def message_of(event)
239
+ data = event.respond_to?(:data) ? event.data : event
240
+ return data["message"] || data[:message] if data.is_a?(Hash)
241
+
242
+ data
243
+ end
244
+ end
245
+
246
+ module_function
247
+
248
+ # Start a Hotwire Native-style app. See NativeApp.
249
+ def native_app(page, **opts)
250
+ NativeApp.new(page, **opts).start
251
+ end
252
+ end
253
+ end