ruflet_rails 0.0.7 → 0.0.8

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.
@@ -1,71 +1,750 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
- require "yaml"
4
+ require "rbconfig"
5
+ require "active_support/core_ext/string/inflections"
5
6
 
6
7
  module Ruflet
7
8
  module Rails
8
9
  module InstallSupport
9
- CLIENT_EXTENSION_MAP = {
10
- "ads" => { package: "flet_ads", alias: "ruflet_ads" },
11
- "audio" => { package: "flet_audio", alias: "ruflet_audio" },
12
- "audio_recorder" => { package: "flet_audio_recorder", alias: "ruflet_audio_recorder" },
13
- "camera" => { package: "flet_camera", alias: "ruflet_camera" },
14
- "charts" => { package: "flet_charts", alias: "ruflet_charts" },
15
- "code_editor" => { package: "flet_code_editor", alias: "ruflet_code_editor" },
16
- "color_pickers" => { package: "flet_color_pickers", alias: "ruflet_color_picker" },
17
- "datatable2" => { package: "flet_datatable2", alias: "ruflet_datatable2" },
18
- "flashlight" => { package: "flet_flashlight", alias: "ruflet_flashlight" },
19
- "geolocator" => { package: "flet_geolocator", alias: "ruflet_geolocator" },
20
- "lottie" => { package: "flet_lottie", alias: "ruflet_lottie" },
21
- "map" => { package: "flet_map", alias: "ruflet_map" },
22
- "permission_handler" => { package: "flet_permission_handler", alias: "ruflet_permission_handler" },
23
- "secure_storage" => { package: "flet_secure_storage", alias: "ruflet_secure_storage" },
24
- "video" => { package: "flet_video", alias: "ruflet_video" },
25
- "webview" => { package: "flet_webview", alias: "ruflet_webview" }
26
- }.freeze
27
-
28
10
  module_function
29
11
 
30
- def default_mobile_app_template(app_title:)
31
- <<~RUBY
12
+ def default_app_template(app_title:)
13
+ template = <<~RUBY
32
14
  require "ruflet"
15
+ require "ruflet_rails"
16
+
17
+ Ruflet::Rails.load_views(__dir__)
33
18
 
34
19
  Ruflet.run do |page|
35
20
  page.title = #{app_title.inspect}
36
- count = 0
37
- count_text = text(count.to_s, size: 40)
38
-
39
- page.add(
40
- container(
41
- expand: true,
42
- alignment: Ruflet::MainAxisAlignment::CENTER,
43
- content: column(
44
- alignment: Ruflet::MainAxisAlignment::CENTER,
45
- horizontal_alignment: Ruflet::CrossAxisAlignment::CENTER,
21
+ Ruflet::Rails.render(page)
22
+ end
23
+ RUBY
24
+ template.gsub(/^ /, " ")
25
+ end
26
+
27
+ def application_component_template
28
+ template = <<~RUBY
29
+ # frozen_string_literal: true
30
+
31
+ # ApplicationComponent is the base class for all Ruflet UI components in
32
+ # this Rails app. It explicitly includes Ruflet::UI::SharedControlForwarders
33
+ # so that every subclass has the full ruflet widget DSL available as
34
+ # instance methods (text, column, row, container, safe_area, filled_button,
35
+ # icon, data_table, alert_dialog, and every other ruflet widget).
36
+ # This is the same DSL that showcase uses — explicit, no Kernel magic.
37
+ class ApplicationComponent
38
+ include Ruflet::UI::SharedControlForwarders
39
+
40
+ attr_reader :page
41
+
42
+ def self.render(page, *args, **kwargs, &block)
43
+ new(page).render(*args, **kwargs, &block)
44
+ end
45
+
46
+ def initialize(page)
47
+ @page = page
48
+ end
49
+
50
+ private
51
+
52
+ # Widget builder calls on this component delegate to Ruflet::DSL,
53
+ # the same target used by the showcase App and by Kernel.
54
+ # Override in a subclass to scope builds to a local WidgetBuilder.
55
+ def control_delegate
56
+ Ruflet::DSL
57
+ end
58
+
59
+ def platform
60
+ page.client_details["platform"].to_s
61
+ end
62
+
63
+ def desktop?
64
+ %w[macos windows linux].include?(platform)
65
+ end
66
+
67
+ def web?
68
+ platform == "web"
69
+ end
70
+
71
+ def mobile?
72
+ !desktop? && !web?
73
+ end
74
+
75
+ def screen_width
76
+ page.client_details["width"].to_f
77
+ end
78
+
79
+ # Returns true when the client is narrower than 600 logical pixels
80
+ # (phones and small tablets), enabling compact list layouts.
81
+ def compact?
82
+ screen_width > 0 && screen_width < 600
83
+ end
84
+ end
85
+ RUBY
86
+ template.gsub(/^ /, " ")
87
+ end
88
+
89
+ def application_component_path
90
+ File.join("app", "views", "ruflet", "components", "application_component.rb")
91
+ end
92
+
93
+ def default_mobile_app_template(app_title:)
94
+ default_app_template(app_title: app_title)
95
+ end
96
+
97
+ def model_names(model_name)
98
+ raw = model_name.to_s.strip
99
+ class_name = raw.camelize
100
+ singular = raw.underscore.singularize
101
+ plural = singular.pluralize
102
+ {
103
+ class_name: class_name,
104
+ singular: singular,
105
+ plural: plural,
106
+ title: plural.humanize.titleize
107
+ }
108
+ end
109
+
110
+ def form_view_path(model_name)
111
+ names = model_names(model_name)
112
+
113
+ File.join("app", "views", "ruflet", "components", names[:plural], "#{names[:singular]}_form.rb")
114
+ end
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
+ def scaffold_component_path(model_name)
123
+ names = model_names(model_name)
124
+
125
+ File.join("app", "views", "ruflet", "components", names[:plural], "#{names[:singular]}_component.rb")
126
+ end
127
+
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
235
+
236
+ end
237
+ RUBY
238
+ template.gsub(/^[ \t]*__DISPLAY_VALUE_CASES__$/, display_value_cases)
239
+ end
240
+
241
+ def scaffold_component_template(model_name:, attributes: [])
242
+ names = model_names(model_name)
243
+ model_class = names[:class_name]
244
+ component_class = "#{model_class}Component"
245
+ attrs = normalized_form_attributes(attributes)
246
+ control_locals = scaffold_control_locals(attrs)
247
+ control_list = scaffold_control_list(attrs)
248
+ attributes_hash = scaffold_attributes_hash(attrs)
249
+
250
+ <<~RUBY
251
+ # frozen_string_literal: true
252
+
253
+ require "date"
254
+ require "ruflet_rails"
255
+
256
+ class #{component_class} < Ruflet::Rails::ResourceComponent
257
+ def render
258
+ safe_area(
259
+ container(
260
+ expand: true,
261
+ padding: { left: 24, top: 16, right: 24, bottom: 24 },
262
+ content: column(
263
+ expand: true,
264
+ spacing: 16,
265
+ children: [
266
+ index_header,
267
+ compact? ? record_list(records) : record_table(records)
268
+ ]
269
+ )
270
+ ),
271
+ expand: true
272
+ )
273
+ end
274
+
275
+ def show(record)
276
+ safe_area(
277
+ container(
278
+ expand: true,
279
+ padding: { left: 24, top: 16, right: 24, bottom: 24 },
280
+ content: column(
281
+ expand: true,
282
+ spacing: 16,
283
+ children: [
284
+ show_header(record),
285
+ column(
286
+ spacing: 8,
287
+ children: resource_fields.map { |field| field_row(field.humanize, display_value(record, field)) }
288
+ )
289
+ ]
290
+ )
291
+ ),
292
+ expand: true
293
+ )
294
+ end
295
+
296
+ private
297
+
298
+ def show_header(record)
299
+ row(
300
+ alignment: "spaceBetween",
301
+ vertical_alignment: "center",
302
+ children: [
303
+ container(expand: true, content: text("\#{singular_title} ##\#{record_id(record)}", size: 24, weight: "bold")),
304
+ row(
305
+ tight: true,
306
+ spacing: 8,
307
+ children: [
308
+ outlined_button(content: text("Back"), on_click: ->(_event) { render_index }),
309
+ filled_button(content: text("Edit"), on_click: ->(_event) { open_form(record) })
310
+ ]
311
+ )
312
+ ]
313
+ )
314
+ end
315
+
316
+ def index_header
317
+ row(
318
+ alignment: "spaceBetween",
319
+ vertical_alignment: "center",
320
+ children: [
321
+ container(expand: true, content: text(resource_title, size: 24, weight: "bold")),
322
+ filled_button(content: text("New \#{singular_title}"), on_click: ->(_event) { open_form(model_class.new) })
323
+ ]
324
+ )
325
+ end
326
+
327
+ def record_table(items)
328
+ row(
329
+ scroll: "auto",
330
+ children: [
331
+ data_table(
332
+ table_columns,
333
+ rows: items.map { |record| table_row(record) },
334
+ column_spacing: 24,
335
+ horizontal_margin: 12,
336
+ show_bottom_border: true
337
+ )
338
+ ]
339
+ )
340
+ end
341
+
342
+ def table_columns
343
+ display_fields.map { |field| data_column(field.humanize) } + [
344
+ data_column("Actions"),
345
+ data_column(""),
346
+ data_column("")
347
+ ]
348
+ end
349
+
350
+ def table_row(record)
351
+ data_row(
352
+ display_fields.map { |field| data_cell(display_value(record, field), on_tap: ->(_event) { open_show(record) }) } +
353
+ [
354
+ data_cell(icon("visibility", tooltip: "Show"), on_tap: ->(_event) { open_show(record) }),
355
+ data_cell(icon("edit", tooltip: "Edit"), on_tap: ->(_event) { open_form(record) }),
356
+ data_cell(icon("delete", tooltip: "Delete"), on_tap: ->(_event) { open_delete(record) })
357
+ ]
358
+ )
359
+ end
360
+
361
+ def record_list(items)
362
+ column(spacing: 4, children: items.map { |record| record_tile(record) })
363
+ end
364
+
365
+ def record_tile(record)
366
+ list_tile(
367
+ title: text(primary_label(record)),
368
+ subtitle: secondary_label(record) ? text(secondary_label(record)) : nil,
369
+ trailing: row(
370
+ tight: true,
371
+ spacing: 0,
372
+ children: [
373
+ icon_button("edit", tooltip: "Edit", on_click: ->(_event) { open_form(record) }),
374
+ icon_button("delete", tooltip: "Delete", on_click: ->(_event) { open_delete(record) })
375
+ ]
376
+ ),
377
+ on_click: ->(_event) { open_show(record) }
378
+ )
379
+ end
380
+
381
+ def open_show(record)
382
+ show_record(record)
383
+ end
384
+
385
+ def open_form(record)
386
+ #{control_locals}
387
+
388
+ attributes = lambda do
389
+ {
390
+ #{attributes_hash}
391
+ }
392
+ end
393
+
394
+ dialog = nil
395
+ dialog = alert_dialog(
396
+ open: false,
397
+ modal: true,
398
+ scrollable: true,
399
+ title: text(record.persisted? ? "Edit \#{singular_title}" : "New \#{singular_title}"),
400
+ content: container(
401
+ width: dialog_width,
402
+ content: column(
403
+ spacing: 8,
404
+ children: [
405
+ #{control_list}
406
+ ]
407
+ )
408
+ ),
409
+ actions: [
410
+ text_button(content: text("Cancel"), on_click: ->(_event) { close_dialog(dialog) }),
411
+ filled_button(content: text("Save"), on_click: ->(_event) {
412
+ save_record(record, attributes.call, dialog)
413
+ })
414
+ ],
415
+ actions_alignment: "end"
416
+ )
417
+ open_dialog(dialog)
418
+ end
419
+
420
+ def open_delete(record)
421
+ dialog = nil
422
+ dialog = alert_dialog(
423
+ open: false,
424
+ modal: true,
425
+ title: text("Delete \#{singular_title}?"),
426
+ content: text("Permanently remove \#{singular_title} #\#{record_id(record)}?", no_wrap: false),
427
+ actions: [
428
+ text_button(content: text("Cancel"), on_click: ->(_event) { close_dialog(dialog) }),
429
+ filled_button(content: text("Delete"), on_click: ->(_event) { destroy_record(record, dialog) })
430
+ ],
431
+ actions_alignment: "end"
432
+ )
433
+ open_dialog(dialog)
434
+ end
435
+
436
+ def field_row(label, value)
437
+ row(
438
+ children: [
439
+ container(width: 140, content: text(label, weight: "bold")),
440
+ container(expand: true, content: text(value, no_wrap: false))
441
+ ]
442
+ )
443
+ end
444
+ end
445
+ RUBY
446
+ end
447
+
448
+ def scaffold_control_locals(attrs)
449
+ attrs.map { |field| scaffold_control_local(field) }.join("\n ")
450
+ end
451
+
452
+ def scaffold_resource_fields(attrs)
453
+ attrs.map { |field| field[:name] }.inspect
454
+ end
455
+
456
+ def scaffold_display_fields(attrs)
457
+ fields = attrs.reject { |field| field[:type].to_s == "text" }
458
+ fields = attrs if fields.empty?
459
+ fields.first(3).map { |field| field[:name] }.inspect
460
+ end
461
+
462
+ def scaffold_display_value_cases(attrs)
463
+ attrs.filter_map do |field|
464
+ next unless %w[date datetime timestamp time date_range daterange].include?(field[:type].to_s)
465
+
466
+ name = field[:name]
467
+ formatter =
468
+ case field[:type].to_s
469
+ when "time"
470
+ "value.respond_to?(:strftime) ? value.strftime(\"%H:%M\") : value.to_s"
471
+ when "date_range", "daterange"
472
+ "value.respond_to?(:begin) && value.respond_to?(:end) ? \"\#{value.begin} - \#{value.end}\" : value.to_s"
473
+ when "date"
474
+ "value.respond_to?(:to_date) ? value.to_date.iso8601 : value.to_s"
475
+ else
476
+ "value.respond_to?(:iso8601) ? value.iso8601 : value.to_s"
477
+ end
478
+ [
479
+ " when #{name.inspect}",
480
+ " value = record.public_send(#{name.inspect})",
481
+ " #{formatter}"
482
+ ].join("\n")
483
+ end.join("\n")
484
+ end
485
+
486
+ def scaffold_control_list(attrs)
487
+ attrs.map { |field| scaffold_control_view_name(field) }.join(",\n ")
488
+ end
489
+
490
+ def scaffold_attributes_hash(attrs)
491
+ attrs.map { |field| scaffold_attribute_pair(field) }.join(",\n ")
492
+ end
493
+
494
+ def scaffold_control_local(field)
495
+ name = field[:name]
496
+ type = field[:type].to_s
497
+ control = scaffold_control_name(field)
498
+ label = name.humanize
499
+ value = "record.public_send(#{name.inspect})"
500
+
501
+ case type
502
+ when "boolean"
503
+ "#{control} = checkbox(label: #{label.inspect}, value: !!#{value})"
504
+ when "date", "datetime", "timestamp"
505
+ display_control = "#{control}_display"
506
+ picker_value_helper = type == "date" ? "date_picker_value" : "datetime_picker_value"
507
+ <<~RUBY.chomp
508
+ #{control}_value = #{picker_value_helper}(#{value})
509
+ #{display_control} = text(date_display_value(#{control}_value))
510
+ #{control} = date_picker(
511
+ value: #{control}_value,
512
+ help_text: #{label.inspect},
513
+ on_change: ->(_event) do
514
+ close_dialogs(#{control})
515
+ page.update(#{display_control}, value: date_display_value(#{control}.props["value"]))
516
+ end
517
+ )
518
+ #{control}_field = column(
519
+ spacing: 6,
520
+ children: [
521
+ text(#{label.inspect}),
522
+ row(
523
+ spacing: 8,
524
+ children: [
525
+ container(expand: true, content: #{display_control}),
526
+ outlined_button(content: text("Choose #{label}"), on_click: ->(_event) { open_dialog(#{control}) })
527
+ ]
528
+ )
529
+ ]
530
+ )
531
+ RUBY
532
+ when "time"
533
+ display_control = "#{control}_display"
534
+ <<~RUBY.chomp
535
+ #{control}_value = time_picker_value(#{value})
536
+ #{display_control} = text(time_display_value(#{control}_value))
537
+ #{control} = time_picker(
538
+ value: #{control}_value,
539
+ help_text: #{label.inspect},
540
+ on_change: ->(_event) do
541
+ close_dialogs(#{control})
542
+ page.update(#{display_control}, value: time_display_value(#{control}.props["value"]))
543
+ end
544
+ )
545
+ #{control}_field = column(
546
+ spacing: 6,
547
+ children: [
548
+ text(#{label.inspect}),
549
+ row(
550
+ spacing: 8,
551
+ children: [
552
+ container(expand: true, content: #{display_control}),
553
+ outlined_button(content: text("Choose #{label}"), on_click: ->(_event) { open_dialog(#{control}) })
554
+ ]
555
+ )
556
+ ]
557
+ )
558
+ RUBY
559
+ when "date_range", "daterange"
560
+ display_control = "#{control}_display"
561
+ <<~RUBY.chomp
562
+ #{control}_start_value, #{control}_end_value = date_range_picker_values(#{value})
563
+ #{display_control} = text(date_range_display_value(#{control}_start_value, #{control}_end_value))
564
+ #{control} = date_range_picker(
565
+ start_value: #{control}_start_value,
566
+ end_value: #{control}_end_value,
567
+ help_text: #{label.inspect},
568
+ on_change: ->(_event) do
569
+ close_dialogs(#{control})
570
+ page.update(
571
+ #{display_control},
572
+ value: date_range_display_value(#{control}.props["start_value"], #{control}.props["end_value"])
573
+ )
574
+ end
575
+ )
576
+ #{control}_field = column(
577
+ spacing: 6,
578
+ children: [
579
+ text(#{label.inspect}),
580
+ row(
581
+ spacing: 8,
582
+ children: [
583
+ container(expand: true, content: #{display_control}),
584
+ outlined_button(content: text("Choose #{label}"), on_click: ->(_event) { open_dialog(#{control}) })
585
+ ]
586
+ )
587
+ ]
588
+ )
589
+ RUBY
590
+ when "text"
591
+ "#{control} = text_field(value: #{value}.to_s, label: #{label.inspect}, multiline: true, min_lines: 3)"
592
+ when "integer", "float", "decimal"
593
+ "#{control} = text_field(value: #{value}.to_s, label: #{label.inspect}, keyboard_type: \"number\")"
594
+ else
595
+ "#{control} = text_field(value: #{value}.to_s, label: #{label.inspect})"
596
+ end
597
+ end
598
+
599
+ def scaffold_attribute_pair(field)
600
+ name = field[:name]
601
+ type = field[:type].to_s
602
+ control = scaffold_control_name(field)
603
+ value =
604
+ case type
605
+ when "boolean"
606
+ "!!#{control}.props[\"value\"]"
607
+ when "date"
608
+ "#{control}.props[\"value\"].to_s.split(\"T\", 2).first"
609
+ when "date_range", "daterange"
610
+ "Range.new(Date.parse(#{control}.props[\"start_value\"].to_s), Date.parse(#{control}.props[\"end_value\"].to_s))"
611
+ else
612
+ "#{control}.props[\"value\"].to_s"
613
+ end
614
+
615
+ "#{name.inspect} => #{value}"
616
+ end
617
+
618
+ def scaffold_control_name(field)
619
+ "#{field[:name].gsub(/[^a-zA-Z0-9_]/, '_')}_control"
620
+ end
621
+
622
+ def scaffold_control_view_name(field)
623
+ control = scaffold_control_name(field)
624
+ %w[date datetime timestamp time date_range daterange].include?(field[:type].to_s) ? "#{control}_field" : control
625
+ end
626
+
627
+ def form_view_template(model_name:, attributes:)
628
+ names = model_names(model_name)
629
+ attrs = normalized_form_attributes(attributes)
630
+ fields_literal = attrs.map { |field| form_field_literal(field) }.join(", ")
631
+ model_class = names[:class_name]
632
+ singular_title = names[:singular].humanize.titleize
633
+
634
+ <<~RUBY
635
+ # frozen_string_literal: true
636
+
637
+ require "ruflet_rails"
638
+
639
+ class #{model_class}Form < ApplicationComponent
640
+ include Ruflet::Rails::FormHelpers
641
+
642
+ def render(record:, title: nil, on_save: nil, on_cancel: nil)
643
+ title ||= record.persisted? ? "Edit #{singular_title}" : "New #{singular_title}"
644
+ fields = ruflet_form_bindings(record, form_fields)
645
+
646
+ column(
647
+ expand: true,
648
+ spacing: 12,
46
649
  children: [
47
- text("You have pushed the button this many times:"),
48
- count_text
650
+ text(title, size: 24, weight: "bold"),
651
+ column(spacing: 8, children: ruflet_form_controls(fields)),
652
+ row(
653
+ spacing: 8,
654
+ children: [
655
+ outlined_button(
656
+ content: text("Cancel"),
657
+ on_click: ->(_e) { on_cancel ? on_cancel.call(page, record) : nil }
658
+ ),
659
+ filled_button(
660
+ content: text(record.persisted? ? "Update #{singular_title}" : "Create #{singular_title}"),
661
+ on_click: ->(_e) { save(record, fields, on_save: on_save) }
662
+ )
663
+ ]
664
+ )
49
665
  ]
50
666
  )
51
- ),
52
- floating_action_button: fab(
53
- icon: Ruflet::MaterialIcons::ADD,
54
- on_click: ->(_e) do
55
- count += 1
56
- page.update(count_text, value: count.to_s)
667
+ end
668
+
669
+ def save(record, fields, on_save: nil)
670
+ if record.update(ruflet_form_attributes(fields, form_fields))
671
+ on_save ? on_save.call(page, record) : record
672
+ else
673
+ show_errors(record)
674
+ false
57
675
  end
58
- )
59
- )
676
+ end
677
+
678
+ def form_fields
679
+ [#{fields_literal}]
680
+ end
681
+
682
+ def show_errors(record)
683
+ show_snackbar(error_message(record))
684
+ end
685
+
686
+ def show_snackbar(message)
687
+ page.snackbar = snackbar(text(message), open: true)
688
+ end
689
+
690
+ def error_message(record)
691
+ messages = record.errors.full_messages
692
+ messages.respond_to?(:to_sentence) ? messages.to_sentence : messages.join(", ")
693
+ end
60
694
  end
61
695
  RUBY
62
696
  end
63
697
 
698
+ def normalized_form_attributes(attributes)
699
+ attrs = Array(attributes).map { |field| normalize_form_attribute(field) }.reject { |field| field[:name].empty? }
700
+ attrs.empty? ? [{ name: "name", type: "string" }] : attrs
701
+ end
702
+
703
+ def attributes_from_model(model_class)
704
+ return [] unless model_class.respond_to?(:columns)
705
+
706
+ model_class.columns.reject { |column|
707
+ %w[id created_at updated_at].include?(column.name)
708
+ }.map { |column| "#{column.name}:#{column.type}" }
709
+ end
710
+
711
+ def form_field_literal(field)
712
+ parts = [
713
+ "name: #{field[:name].inspect}",
714
+ "type: #{field[:type].inspect}"
715
+ ]
716
+ parts << "class_name: #{field[:class_name].inspect}" if field[:class_name]
717
+ "{ #{parts.join(', ')} }"
718
+ end
719
+
720
+ def normalize_form_attribute(value)
721
+ raw = value.to_s.strip
722
+ name, type = raw.split(":", 2)
723
+ name = name.to_s.underscore.gsub(/[^a-z0-9_]/, "")
724
+ type = type.to_s.strip
725
+ type = "string" if type.empty?
726
+ name = "#{name}_id" if %w[references belongs_to association].include?(type) && !name.end_with?("_id")
727
+ association = association_class_name_for(name, type)
728
+ {
729
+ name: name,
730
+ type: association ? "association" : type
731
+ }.tap do |field|
732
+ field[:class_name] = association if association
733
+ end
734
+ end
735
+
736
+ def association_class_name_for(name, type)
737
+ return name.sub(/_id\z/, "").camelize if name.end_with?("_id")
738
+ return name.camelize if %w[references belongs_to association].include?(type)
739
+
740
+ nil
741
+ end
742
+
64
743
  def default_ruflet_yaml(app_name:)
65
744
  <<~YAML
66
745
  app:
67
746
  name: #{app_name}
68
- ruflet_client_url: ""
747
+ backend_url: #{default_backend_url}
69
748
 
70
749
  services: []
71
750
 
@@ -75,129 +754,228 @@ module Ruflet
75
754
  YAML
76
755
  end
77
756
 
78
- def route_snippet(entrypoint: "app/mobile/main.rb", mount_path: "/ws")
79
- %(mount Ruflet::Rails.mobile(Rails.root.join("#{entrypoint}")), at: "#{mount_path}")
757
+ def desktop_initializer_path
758
+ File.join("config", "initializers", "ruflet_desktop.rb")
80
759
  end
81
760
 
82
- def client_template_root
83
- env = ENV["RUFLET_CLIENT_TEMPLATE_DIR"].to_s.strip
84
- return env if !env.empty? && Dir.exist?(env)
761
+ def desktop_initializer_template
762
+ <<~RUBY
763
+ # frozen_string_literal: true
85
764
 
86
- candidates = [
87
- File.expand_path("../../../../../ruflet_client", __dir__),
88
- File.expand_path("../../../../../templates/ruflet_flutter_template", __dir__)
89
- ]
90
- candidates.find { |path| Dir.exist?(path) }
765
+ # Set this to true when you intentionally want the Rails server process to
766
+ # launch the server-driven Ruflet desktop client.
767
+ Rails.application.configure do
768
+ config.x.ruflet_rails.desktop = false
769
+ end
770
+ RUBY
91
771
  end
92
772
 
93
- def copy_ruflet_client_template(root)
94
- template_root = client_template_root
95
- return false unless template_root
773
+ def ruby_desktop_flag_bootstrap
774
+ <<~RUBY
775
+ # ruflet_rails desktop flag
776
+ ruflet_rails_desktop = ARGV.include?("--desktop")
777
+ ruflet_rails_command = ARGV.find { |value| !value.to_s.start_with?("-") }
778
+ if ruflet_rails_desktop && %w[server s].include?(ruflet_rails_command.to_s)
779
+ ENV["RUFLET_RAILS_DESKTOP"] = "true"
780
+ ENV["RUFLET_RAILS_DESKTOP_SERVER"] = "true"
781
+ end
782
+ ARGV.delete("--desktop")
96
783
 
97
- target = File.join(root, "ruflet_client")
98
- return true if Dir.exist?(target)
784
+ RUBY
785
+ end
99
786
 
100
- FileUtils.cp_r(template_root, target)
101
- prune_client_template(target)
102
- true
787
+ def ruby_dev_desktop_flag_bootstrap
788
+ <<~RUBY
789
+ # ruflet_rails desktop flag
790
+ if ARGV.delete("--desktop")
791
+ ENV["RUFLET_RAILS_DESKTOP"] = "true"
792
+ ENV["RUFLET_RAILS_DESKTOP_SERVER"] = "true"
793
+ end
794
+
795
+ RUBY
103
796
  end
104
797
 
105
- def prune_client_template(target)
106
- %w[
107
- .dart_tool
108
- .idea
109
- build
110
- ios/Pods
111
- ios/.symlinks
112
- ios/Podfile.lock
113
- macos/Pods
114
- macos/Podfile.lock
115
- android/.gradle
116
- android/.kotlin
117
- android/local.properties
118
- ].each do |path|
119
- full = File.join(target, path)
120
- FileUtils.rm_rf(full) if File.exist?(full)
121
- end
798
+ def shell_desktop_flag_bootstrap
799
+ <<~SH
800
+ # ruflet_rails desktop flag
801
+ if [ "$1" = "--desktop" ]; then
802
+ export RUFLET_RAILS_DESKTOP=true
803
+ export RUFLET_RAILS_DESKTOP_SERVER=true
804
+ shift
805
+ fi
806
+
807
+ SH
122
808
  end
123
809
 
124
- def configure_ruflet_client(root)
125
- config_path = File.join(root, "ruflet.yaml")
126
- return unless File.file?(config_path)
810
+ def default_backend_url
811
+ "http://localhost:3000"
812
+ end
127
813
 
128
- config = YAML.safe_load(File.read(config_path), aliases: true) || {}
129
- extension_keys = Array(config["services"]).map { |v| normalize_extension_key(v) }.compact.uniq
130
- extension_packages = extension_keys.filter_map { |key| CLIENT_EXTENSION_MAP[key]&.fetch(:package) }.uniq
131
- extension_aliases = extension_keys.filter_map { |key| CLIENT_EXTENSION_MAP[key]&.fetch(:alias) }.uniq
814
+ def host_desktop_platform
815
+ host_os = RbConfig::CONFIG["host_os"]
816
+ return "macos" if host_os.match?(/darwin/i)
817
+ return "linux" if host_os.match?(/linux/i)
818
+ return "windows" if host_os.match?(/mswin|mingw|cygwin/i)
132
819
 
133
- client_dir = File.join(root, "ruflet_client")
134
- return unless Dir.exist?(client_dir)
820
+ nil
821
+ end
135
822
 
136
- prune_client_pubspec(File.join(client_dir, "pubspec.yaml"), extension_packages)
137
- prune_client_main(File.join(client_dir, "lib", "main.dart"), extension_aliases)
823
+ def normalize_build_platform(platform)
824
+ value = platform.to_s.strip.downcase
825
+ return host_desktop_platform if value == "desktop"
826
+
827
+ value
138
828
  end
139
829
 
140
- def normalize_extension_key(value)
141
- key = value.to_s.strip.downcase
142
- return nil if key.empty?
830
+ def build_args_for_platform(platform)
831
+ normalized = normalize_build_platform(platform)
832
+ return [] if normalized.to_s.empty?
143
833
 
144
- key.tr!("-", "_")
145
- key.gsub!(/\A(flet_)+/, "")
146
- key.gsub!(/\Aservice_/, "")
147
- key.gsub!(/\Acontrol_/, "")
148
- key = "file_picker" if key == "filepicker"
149
- key
834
+ args = [normalized]
835
+ args += ["--base-href", web_base_href] if normalized == "web"
836
+ args
150
837
  end
151
838
 
152
- def prune_client_pubspec(path, selected_packages)
153
- return unless File.file?(path)
839
+ def default_entrypoint_path
840
+ File.join("app", "views", "ruflet", "main.rb")
841
+ end
154
842
 
155
- data = YAML.safe_load(File.read(path), aliases: true) || {}
156
- deps = (data["dependencies"] || {}).dup
157
- deps.keys.each do |name|
158
- next unless name.start_with?("flet_")
159
- next if name == "flet"
160
- next if selected_packages.include?(name)
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
161
846
 
162
- deps.delete(name)
163
- end
164
- data["dependencies"] = deps
165
- File.write(path, YAML.dump(data))
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}/"
166
854
  end
167
855
 
168
- def prune_client_main(path, selected_aliases)
169
- return unless File.file?(path)
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?
170
869
 
171
- lines = File.readlines(path)
172
- alias_to_package = {}
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"))
173
873
 
174
- lines.each do |line|
175
- match = line.match(%r{\Aimport 'package:(flet_[^/]+)/\1\.dart' as ([a-zA-Z0-9_]+);})
176
- alias_to_package[match[2]] = match[1] if match
874
+ source
875
+ end
876
+
877
+ def prebuilt_client_cache_root(platform: host_desktop_platform)
878
+ require "ruflet/cli"
879
+
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)
177
884
  end
885
+ end
886
+
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"))
178
890
 
179
- kept = lines.select do |line|
180
- import_match = line.match(%r{\Aimport 'package:(flet_[^/]+)/\1\.dart' as ([a-zA-Z0-9_]+);})
181
- if import_match
182
- package_name = import_match[1]
183
- next true if package_name == "flet"
184
- next true if selected_aliases.include?(import_match[2])
185
- next false
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
898
+ end
899
+
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}">))
186
911
  end
912
+ File.write(index_path, updated)
913
+ end
187
914
 
188
- extension_match = line.match(/\A\s*([a-zA-Z0-9_]+)\.Extension\(\),\s*\z/)
189
- if extension_match
190
- extension_alias = extension_match[1]
191
- package_name = alias_to_package[extension_alias]
192
- next true if package_name.nil?
193
- next true if selected_aliases.include?(extension_alias)
194
- next false
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>")
195
940
  end
941
+ File.write(index_path, updated)
942
+ end
943
+
944
+ def install_next_steps(target:, entrypoint:, client:, web_published:, mount_path: "/ws")
945
+ web_path = default_web_public_path
946
+ lines = [
947
+ "Ruflet Rails installed.",
948
+ "Generated entrypoint: #{entrypoint}",
949
+ "Mounted websocket: #{mount_path}",
950
+ "Next steps:",
951
+ " 1. Start Rails: bin/rails server",
952
+ " 2. Open the Ruflet web client: /#{web_path}/"
953
+ ]
954
+
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
196
967
 
197
- true
968
+ if %w[desktop all].include?(client.to_s)
969
+ lines += [
970
+ "Desktop clients are server-driven and connect to this Rails app.",
971
+ "Plain bin/dev, bin/rails server, and bin/rails s do not launch desktop.",
972
+ "To launch desktop for a dev server run: bin/rails s --desktop or bin/dev --desktop",
973
+ "To download the prebuilt desktop client: bin/rails ruflet:update[desktop]",
974
+ "To build the host desktop client: bin/rails ruflet:build[desktop]"
975
+ ]
198
976
  end
199
977
 
200
- File.write(path, kept.join)
978
+ lines
201
979
  end
202
980
  end
203
981
  end