diff --git a/examples/calculator.rb b/examples/calculator.rb deleted file mode 100644 index b62fb12f..00000000 --- a/examples/calculator.rb +++ /dev/null @@ -1,213 +0,0 @@ -require "ruflet" - -class CalculatorApp < Ruflet::App - DIGITS = %w[0 1 2 3 4 5 6 7 8 9].freeze - - def initialize - super - reset - end - - def view(page) - page.title = "Calculator" - page.bgcolor = "#000000" - - @display_control = text( - value: @display, - text_align: "right", - style: { size: 84, color: "#FFFFFF" } - ) - - page.add( - container( - expand: true, - bgcolor: "#000000", - padding: 12, - content: column( - expand: true, - spacing: 12, - children: [ - container(height: 24), - row(alignment: "end", children: [@display_control]), - - # pushes keypad toward bottom - container(expand: true), - - # gap between result and keyboard - container(height: 20), - - keypad_row(page, "BS", "AC", "%", "/"), - keypad_row(page, "7", "8", "9", "x"), - keypad_row(page, "4", "5", "6", "-"), - keypad_row(page, "1", "2", "3", "+"), - keypad_row(page, "+/-", "0", ".", "=") - ] - ) - ) - ) - end - - private - - def keypad_row(page, *labels) - row( - alignment: "center", - spacing: 10, - children: labels.map do |label| - elevated_button( - content: text(value: label), - expand: true, - height: 65, - color: "#FFFFFF", - bgcolor: key_bg(label), - on_click: ->(e) { handle_input(label, e) } - ) - end - ) - end - - def key_bg(label) - operator_label?(label) ? "#FF9F0A" : "#2C2C2E" - end - - def handle_input(label, event) - if DIGITS.include?(label) - on_digit(label) - elsif label == "." - on_decimal - elsif label == "x" - on_operator("x") - elsif label == "/" - on_operator("/") - elsif label == "-" - on_operator("-") - elsif label == "+" - on_operator("+") - elsif label == "=" - on_equals - elsif label == "AC" - reset - elsif label == "+/-" - on_toggle_sign - elsif label == "%" - on_percent - elsif label == "BS" - on_backspace - end - - event.page.update(@display_control, value: @display) - end - - def on_digit(digit) - if @start_new_value || @display == "Error" - @display = digit - @start_new_value = false - return - end - - @display = (@display == "0" ? digit : "#{@display}#{digit}") - end - - def on_decimal - if @start_new_value || @display == "Error" - @display = "0." - @start_new_value = false - return - end - - @display += "." unless @display.include?(".") - end - - def on_operator(next_operator) - if @operator && !@start_new_value - apply_calculation - return if @display == "Error" - else - @operand = to_number(@display) - end - - @operator = next_operator - @start_new_value = true - end - - def on_equals - return unless @operator - - apply_calculation - @operator = nil if @display != "Error" - end - - def on_toggle_sign - return if @display == "0" || @display == "Error" - - @display = @display.start_with?("-") ? @display[1..] : "-#{@display}" - end - - def on_percent - return if @display == "Error" - - @display = format_number(to_number(@display) / 100.0) - @start_new_value = true - end - - def on_backspace - return if @display == "Error" - - if @display.length <= 1 || (@display.length == 2 && @display.start_with?("-")) - @display = "0" - return - end - - @display = @display[0...-1] - end - - def apply_calculation - right = to_number(@display) - result = case @operator - when "+" then @operand + right - when "-" then @operand - right - when "x" then @operand * right - when "/" - return show_error if right.zero? - - @operand / right - end - - @display = format_number(result) - @operand = to_number(@display) - @start_new_value = true - end - - def to_number(value) - Float(value) - rescue StandardError - 0.0 - end - - def format_number(value) - value = value.to_f - return value.to_i.to_s if value == value.to_i - - value.to_s.sub(/\.?0+\z/, "") - end - - def show_error - @display = "Error" - @operator = nil - @operand = nil - @start_new_value = true - end - - def reset - @display = "0" - @operand = nil - @operator = nil - @start_new_value = false - end - - def operator_label?(label) - %w[/ x - + =].include?(label) - end -end - -CalculatorApp.new.run diff --git a/examples/ruflet_studio/app.rb b/examples/ruflet_studio/app.rb index 884b7553..c7e8cb12 100644 --- a/examples/ruflet_studio/app.rb +++ b/examples/ruflet_studio/app.rb @@ -24,7 +24,7 @@ class App < Ruflet::App def view(page) page.title = "Gallery" page.scroll = "auto" - page.bgcolor = "#111318" + page.bgcolor = color_bg(page) page.on_route_change = ->(_e) { render(page) } @@ -36,6 +36,7 @@ def view(page) def render(page) route = (page.route || "/gallery").split("?").first route = "/gallery" if route == "/" + page.bgcolor = color_bg(page) case route when "/home" @@ -46,7 +47,8 @@ def render(page) page.views = [settings_view(page)] when "/counter" page.views = [detail_view(page, "Counter", build_counter(page, status_text(page)), - source_path: "examples/ruflet_studio/sections_controls/counter.rb")] + source_path: "examples/ruflet_studio/sections_controls + /counter.rb")] when "/todo" page.views = [detail_view(page, "To-do", build_todo(page, status_text(page)), source_path: "examples/ruflet_studio/sections_controls/todo.rb")] @@ -80,6 +82,12 @@ def render(page) when "/video" page.views = [detail_view(page, "Video Player", build_video(page, status_text(page)), source_path: "examples/ruflet_studio/sections_media/video.rb")] + when "/webview" + page.views = [detail_view(page, "WebView", build_webview(page, status_text(page)), + source_path: "examples/ruflet_studio/sections_media/webview.rb", + scroll: nil, + horizontal_alignment: "stretch", + padding: 0)] when "/flashlight" page.views = [detail_view(page, "Flashlight", build_flashlight(page, status_text(page)), source_path: "examples/ruflet_studio/sections_media/flashlight.rb")] diff --git a/examples/ruflet_studio/helpers.rb b/examples/ruflet_studio/helpers.rb index aeeb496f..c84da349 100644 --- a/examples/ruflet_studio/helpers.rb +++ b/examples/ruflet_studio/helpers.rb @@ -60,7 +60,10 @@ def effective_theme(page) end def set_theme(page, mode) - @theme_mode = %w[system light dark].include?(mode) ? mode : "system" + normalized = mode.to_s.strip.downcase + return unless %w[system light dark].include?(normalized) + + @theme_mode = normalized page.go(page.route || "/settings") end @@ -72,16 +75,22 @@ def theme_colors(page) text: "#1f2328", subtle: "#6c757d", icon: "#495057", - divider: "#dee2e6" + divider: "#dee2e6", + panel: "#f1f3f5", + nav_indicator: "#dbe4ff", + accent: "#4c6ef5" } else { - bg: "#111318", - surface: "#111318", - text: "#e7e9ec", - subtle: "#9aa0a6", - icon: "#cfd4da", - divider: "#2a2e36" + bg: "#e8edf3", + surface: "#f8fafc", + text: "#1f2328", + subtle: "#5c6773", + icon: "#3f4954", + divider: "#cfd6de", + panel: "#eef2f6", + nav_indicator: "#cfe0ff", + accent: "#3b5bdb" } end end @@ -93,14 +102,18 @@ def color_text(page) = theme_colors(page)[:text] def color_subtle(page) = theme_colors(page)[:subtle] def color_icon(page) = theme_colors(page)[:icon] def color_divider(page) = theme_colors(page)[:divider] + def color_panel(page) = theme_colors(page)[:panel] + def color_nav_indicator(page) = theme_colors(page)[:nav_indicator] + def color_accent(page) = theme_colors(page)[:accent] def read_number(data, key) return nil unless data return data if data.is_a?(Numeric) return data.to_f if data.is_a?(String) && data.match?(/\A-?\d+(\.\d+)?\z/) - return data[key] if data.is_a?(Hash) && data[key].is_a?(Numeric) - if data.is_a?(Hash) && data[key] - return data[key].to_f + if data.is_a?(Hash) + raw = data[key] || data[key.to_s] || data[key.to_sym] + return raw if raw.is_a?(Numeric) + return raw.to_f if raw end nil end @@ -108,7 +121,10 @@ def read_number(data, key) def read_string(data, key) return nil unless data return data if data.is_a?(String) - return data[key] if data.is_a?(Hash) && data[key].is_a?(String) + if data.is_a?(Hash) + raw = data[key] || data[key.to_s] || data[key.to_sym] + return raw if raw.is_a?(String) + end nil end diff --git a/examples/ruflet_studio/icon_search.rb b/examples/ruflet_studio/icon_search.rb deleted file mode 100644 index 418914a3..00000000 --- a/examples/ruflet_studio/icon_search.rb +++ /dev/null @@ -1,146 +0,0 @@ -# frozen_string_literal: true - -require "ruflet" - -class IconSearchApp < Ruflet::App - MAX_RESULTS = 80 - - def initialize - super - @query = "" - @summary_control = nil - @results_grid = nil - @copy_status_control = nil - end - - def view(page) - page.title = "Icon Search" - render(page) - end - - private - - def render(page) - names = filtered_icon_names(@query) - @summary_control = text(value: summary_text(names), style: { size: 12, color: "#6c757d" }) - @copy_status_control = text(value: "Tap an item to copy icon name", style: { size: 12, color: "#6c757d" }) - @results_grid = build_results_grid(names) - - page.add( - container( - expand: true, - padding: 16, - alignment: Ruflet::MainAxisAlignment::CENTER, - content: column( - expand: true, - alignment: Ruflet::MainAxisAlignment::CENTER, - horizontal_alignment: Ruflet::CrossAxisAlignment::CENTER, - spacing: 12, - children: [ - text_field( - label: "Search Material icons", - value: @query, - autofocus: true, - on_change: ->(e) { - @query = event_value(e) - update_results(page) - } - ), - @summary_control, - @copy_status_control, - @results_grid - ] - ) - ), - appbar: app_bar( - title: text(value: "Icon Search") - ) - ) - end - - def update_results(page) - names = filtered_icon_names(@query) - page.update(@summary_control, value: summary_text(names)) - page.update(@results_grid, controls: grid_items(names)) - page.update(@copy_status_control, value: "Tap an item to copy icon name", style: { color: "#6c757d" }) - end - - def build_results_grid(names) - grid_view( - expand: true, - runs_count: 3, - max_extent: 220, - child_aspect_ratio: 2.0, - spacing: 10, - run_spacing: 10, - controls: grid_items(names) - ) - end - - def grid_items(names) - names.map { |name| icon_tile(name) } - end - - def icon_tile(name) - container( - padding: 10, - border_radius: 8, - on_click: ->(e) { copy_icon_name(e.page, name) }, - content: row( - spacing: 8, - children: [ - icon(icon: Ruflet::MaterialIcons.const_get(name)), - container( - expand: true, - content: text(value: name, max_lines: 1, ellipsis: true) - ) - ] - ) - ) - end - - def copy_icon_name(page, name) - call_id = page.set_clipboard(name) - if call_id - @copy_status_control.props["value"] = "Copied: #{name}" - puts "Copied to clipboard: #{name}" - @copy_status_control.props["color"] = "#2b8a3e" - else - @copy_status_control.props["value"] = "Copy failed: clipboard service unavailable" - @copy_status_control.props["color"] = "#c92a2a" - end - page.update(@copy_status_control, value: @copy_status_control.props["value"], style: { color: @copy_status_control.props["color"] }) - rescue StandardError => e - page.update(@copy_status_control, value: "Copy failed: #{e.message}", style: { color: "#c92a2a" }) - end - - def event_value(event) - data = event.data - return data if data.is_a?(String) - return data["value"].to_s if data.is_a?(Hash) && data["value"] - - "" - end - - def summary_text(names) - total = icon_names.size - shown = names.size - query = @query.to_s.strip - return "Type to search icons (#{total} available)" if query.empty? - - "Showing #{shown} results for \"#{query}\"" - end - - def icon_names - @icon_names ||= Ruflet::MaterialIcons.constants(false).map(&:to_s).sort - end - - def filtered_icon_names(query) - q = query.to_s.strip.upcase - return [] if q.empty? - - icon_names.select { |name| name.include?(q) }.first(MAX_RESULTS) - end -end - -IconSearchApp.new.run diff --git a/examples/ruflet_studio/sections_charts.rb b/examples/ruflet_studio/sections_charts.rb index 21f66521..4fbd050b 100644 --- a/examples/ruflet_studio/sections_charts.rb +++ b/examples/ruflet_studio/sections_charts.rb @@ -7,8 +7,8 @@ def build_charts(page, status) width: 320, height: 180, max_y: 110, - border: { width: 1, color: "#2a2e36" }, - horizontal_grid_lines: { color: "#2a2e36", width: 1, dash_pattern: [3, 3] }, + border: { width: 1, color: color_divider(page) }, + horizontal_grid_lines: { color: color_divider(page), width: 1, dash_pattern: [3, 3] }, tooltip: nil, left_axis: chart_axis(label_size: 40, title: text(value: "Fruit supply"), title_size: 40), right_axis: chart_axis(show_labels: false), @@ -125,17 +125,17 @@ def build_charts(page, status) spacing: 12, tight: true, children: [ - text(value: "BarChart", style: { size: 14, weight: "w600", color: "#e7e9ec" }), + text(value: "BarChart", style: { size: 14, weight: "w600" }), bar_chart, - text(value: "LineChart", style: { size: 14, weight: "w600", color: "#e7e9ec" }), + text(value: "LineChart", style: { size: 14, weight: "w600" }), line_chart, - text(value: "PieChart", style: { size: 14, weight: "w600", color: "#e7e9ec" }), + text(value: "PieChart", style: { size: 14, weight: "w600" }), pie_chart, - text(value: "CandlestickChart", style: { size: 14, weight: "w600", color: "#e7e9ec" }), + text(value: "CandlestickChart", style: { size: 14, weight: "w600" }), candlestick_chart, - text(value: "RadarChart", style: { size: 14, weight: "w600", color: "#e7e9ec" }), + text(value: "RadarChart", style: { size: 14, weight: "w600" }), radar_chart, - text(value: "ScatterChart", style: { size: 14, weight: "w600", color: "#e7e9ec" }), + text(value: "ScatterChart", style: { size: 14, weight: "w600" }), scatter_chart ] ) diff --git a/examples/ruflet_studio/sections_controls/calculator.rb b/examples/ruflet_studio/sections_controls/calculator.rb index ecd640ff..e3aa1d48 100644 --- a/examples/ruflet_studio/sections_controls/calculator.rb +++ b/examples/ruflet_studio/sections_controls/calculator.rb @@ -6,15 +6,16 @@ module SectionsControls def build_calculator(page, status) container( - expand: true, + width: 420, padding: 12, + border_radius: 12, + bgcolor: color_panel(page), content: column( - expand: true, spacing: 12, children: [ + status, container(height: 24), row(alignment: "end", children: [calculator_display(status)]), - container(expand: true), container(height: 20), calculator_keypad_row(page, status, "BS", "AC", "%", "/"), calculator_keypad_row(page, status, "7", "8", "9", "x"), @@ -34,29 +35,29 @@ def calculator_display(_status) @calculator_display = text( value: calculator_state[:display], text_align: "right", - style: { size: 84, color: "#FFFFFF" } + style: { size: 84 } ) end def calculator_keypad_row(page, status, *labels) row( alignment: "center", - spacing: 10, + spacing: 6, children: labels.map do |label| elevated_button( content: text(value: label), - expand: true, + width: 78, height: 65, color: "#FFFFFF", - bgcolor: calculator_key_bg(label), + bgcolor: calculator_key_bg(page, label), on_click: ->(e) { calculator_handle_input(label, e, page, status) } ) end ) end - def calculator_key_bg(label) - %w[/ x - + =].include?(label) ? "#FF9F0A" : "#2C2C2E" + def calculator_key_bg(page, label) + %w[/ x - + =].include?(label) ? color_accent(page) : color_surface(page) end def calculator_handle_input(label, event, page, status) diff --git a/examples/ruflet_studio/sections_controls/counter.rb b/examples/ruflet_studio/sections_controls/counter.rb index a8cf295e..96389dab 100644 --- a/examples/ruflet_studio/sections_controls/counter.rb +++ b/examples/ruflet_studio/sections_controls/counter.rb @@ -4,24 +4,44 @@ module RufletStudio module SectionsControls def build_counter(page, status) count = 0 - value = text_field(value: count.to_s, text_align: "right", width: 80) + value = text(value: count.to_s, style: { size: 28 }) - row( - spacing: 8, - alignment: "center", - children: [ - icon_button(icon: "remove", on_click: ->(_e) { - count -= 1 - page.update(value, value: count.to_s) - page.update(status, value: "Counter: #{count}") - }), - value, - icon_button(icon: "add", on_click: ->(_e) { - count += 1 - page.update(value, value: count.to_s) - page.update(status, value: "Counter: #{count}") - }) - ] + container( + width: 320, + padding: 12, + border_radius: 12, + bgcolor: color_panel(page), + content: column( + spacing: 12, + children: [ + status, + row(alignment: "center", children: [value]), + row( + alignment: "center", + spacing: 10, + children: [ + elevated_button( + width: 120, + content: text(value: "-1"), + on_click: ->(_e) { + count -= 1 + page.update(value, value: count.to_s) + page.update(status, value: "Counter: #{count}") + } + ), + elevated_button( + width: 120, + content: text(value: "+1"), + on_click: ->(_e) { + count += 1 + page.update(value, value: count.to_s) + page.update(status, value: "Counter: #{count}") + } + ) + ] + ), + ] + ) ) end end diff --git a/examples/ruflet_studio/sections_controls/cupertino_controls.rb b/examples/ruflet_studio/sections_controls/cupertino_controls.rb index 248557dc..8681966d 100644 --- a/examples/ruflet_studio/sections_controls/cupertino_controls.rb +++ b/examples/ruflet_studio/sections_controls/cupertino_controls.rb @@ -18,9 +18,9 @@ def build_cupertino_controls(page, status) use_magnifier: true, item_extent: 32, children: [ - text(value: "One", style: { color: "#111318" }), - text(value: "Two", style: { color: "#111318" }), - text(value: "Three", style: { color: "#111318" }) + text(value: "One"), + text(value: "Two"), + text(value: "Three") ] ) diff --git a/examples/ruflet_studio/sections_controls/material_controls.rb b/examples/ruflet_studio/sections_controls/material_controls.rb index bba1d165..c9f55533 100644 --- a/examples/ruflet_studio/sections_controls/material_controls.rb +++ b/examples/ruflet_studio/sections_controls/material_controls.rb @@ -36,7 +36,7 @@ def build_material_controls(page, status) content: column( spacing: 8, children: [ - text(value: "TextField", style: { size: 14, weight: "w600", color: "#1f2328" }), + text(value: "TextField", style: { size: 14, weight: "w600" }), text_field(label: "Name", value: "Ruflet") ] ) @@ -49,7 +49,7 @@ def build_material_controls(page, status) content: column( spacing: 8, children: [ - text(value: "Buttons", style: { size: 14, weight: "w600", color: "#1f2328" }), + text(value: "Buttons", style: { size: 14, weight: "w600" }), row( spacing: 8, children: [ @@ -69,7 +69,7 @@ def build_material_controls(page, status) content: column( spacing: 8, children: [ - text(value: "Selection", style: { size: 14, weight: "w600", color: "#1f2328" }), + text(value: "Selection", style: { size: 14, weight: "w600" }), control(:switch, label: "Wi-Fi", value: true), control(:slider, min: 0, max: 100, divisions: 10, value: 35, label: "Value = {value}") ] @@ -83,7 +83,7 @@ def build_material_controls(page, status) content: column( spacing: 8, children: [ - text(value: "Dialogs", style: { size: 14, weight: "w600", color: "#1f2328" }), + text(value: "Dialogs", style: { size: 14, weight: "w600" }), text_button(content: text(value: "Show dialog"), on_click: ->(_e) { page.show_dialog(material_dialog) }) ] ) @@ -96,7 +96,7 @@ def build_material_controls(page, status) content: column( spacing: 8, children: [ - text(value: "Banners", style: { size: 14, weight: "w600", color: "#1f2328" }), + text(value: "Banners", style: { size: 14, weight: "w600" }), text_button(content: text(value: "Show banner"), on_click: ->(_e) { page.show_dialog(build_banner.call) }) diff --git a/examples/ruflet_studio/sections_controls/todo.rb b/examples/ruflet_studio/sections_controls/todo.rb index 722267d8..41d23274 100644 --- a/examples/ruflet_studio/sections_controls/todo.rb +++ b/examples/ruflet_studio/sections_controls/todo.rb @@ -65,7 +65,7 @@ def build_todo(page, _status) column( spacing: 8, children: [ - text(value: "Todos", style: { size: 20, weight: "w600", color: "#e7e9ec" }), + text(value: "Todos", style: { size: 20, weight: "w600" }), input, button(content: text(value: "Add"), on_click: ->(_e) { add_todo.call }), list, diff --git a/examples/ruflet_studio/sections_media.rb b/examples/ruflet_studio/sections_media.rb index 03224af2..b360b749 100644 --- a/examples/ruflet_studio/sections_media.rb +++ b/examples/ruflet_studio/sections_media.rb @@ -6,6 +6,7 @@ require_relative "sections_media/flashlight" require_relative "sections_media/camera" require_relative "sections_media/file_picker" +require_relative "sections_media/webview" module RufletStudio module SectionsMedia diff --git a/examples/ruflet_studio/sections_media/camera.rb b/examples/ruflet_studio/sections_media/camera.rb index 211089e1..4d6baff5 100644 --- a/examples/ruflet_studio/sections_media/camera.rb +++ b/examples/ruflet_studio/sections_media/camera.rb @@ -6,58 +6,80 @@ def build_camera(page, status) camera = page.service( :camera, preview_enabled: true, - expand: true, on_error: ->(e) { page.update(status, value: "Camera error: #{e.data}") } ) + camera_busy = false + open_button = nil preview = container( visible: false, height: 320, border_radius: 10, - bgcolor: "#000000", + bgcolor: color_panel(page), border: { width: 1, color: color_divider(page) }, content: camera ) + open_button = button( + content: text(value: "Open camera"), + on_click: ->(_e) do + next if camera_busy + camera_busy = true + page.update(open_button, disabled: true) + page.update(status, value: "Checking available cameras...") + page.invoke( + camera, + "get_available_cameras", + timeout: 45, + on_result: lambda { |result, error| + if error && !error.to_s.empty? + camera_busy = false + page.update(open_button, disabled: false) + page.update(status, value: "Camera error: #{error}") + next + end + + cameras = Array(result) + if cameras.empty? + camera_busy = false + page.update(open_button, disabled: false) + page.update(status, value: "No camera available on this device.") + next + end + + page.update(status, value: "Initializing camera...") + page.invoke( + camera, + "initialize", + args: { + "description" => cameras.first, + "resolution_preset" => "medium", + "enable_audio" => false, + "image_format_group" => "jpeg" + }, + timeout: 180, + on_result: lambda { |_init_result, init_error| + camera_busy = false + page.update(open_button, disabled: false) + if init_error && !init_error.to_s.empty? + page.update(status, value: "Camera error: #{init_error}") + else + page.update(preview, visible: true) + page.update(status, value: "Camera initialized.") + end + } + ) + } + ) + end + ) + column( spacing: 10, children: [ status, - button( - content: text(value: "Open camera"), - on_click: ->(_e) do - page.update(status, value: "Checking available cameras...") - page.invoke(camera, "get_available_cameras", timeout: 30, on_result: lambda { |result, error| - if error - page.update(status, value: "Camera error: #{error}") - next - end - - cameras = Array(result) - if cameras.empty? - page.update(status, value: "No camera available on this device.") - next - end - - page.update(status, value: "Initializing camera...") - page.invoke( - camera, - "initialize", - args: { "description" => cameras.first }, - timeout: 60, - on_result: lambda { |_init_result, init_error| - if init_error - page.update(status, value: "Camera init error: #{init_error}") - else - page.update(preview, visible: true) - page.update(status, value: "Camera ready.") - end - } - ) - }) - end - ), - text(value: "Tap Open camera to initialize and show preview.", style: { size: 12, color: color_subtle(page) }), + open_button, + text(value: "Tap Open camera to initialize and show preview.", style: { size: 12 }), preview ] ) diff --git a/examples/ruflet_studio/sections_media/file_picker.rb b/examples/ruflet_studio/sections_media/file_picker.rb index 403460bd..574562de 100644 --- a/examples/ruflet_studio/sections_media/file_picker.rb +++ b/examples/ruflet_studio/sections_media/file_picker.rb @@ -1,53 +1,52 @@ # frozen_string_literal: true -require "json" - module RufletStudio module SectionsMedia def build_file_picker(page, status) - file_picker = page.service(:file_picker) + file_picker = page.service(:filepicker) + picker_busy = false + open_button = nil + + open_button = button( + content: text(value: "Open file picker"), + on_click: ->(_e) do + next if picker_busy + picker_busy = true + page.update(open_button, disabled: true) + page.update(status, value: "Opening file picker...") + page.invoke( + file_picker, + "pick_files", + args: { "allow_multiple" => false, "with_data" => false }, + timeout: 600, + on_result: lambda { |result, error| + picker_busy = false + page.update(open_button, disabled: false) + + if error && !error.to_s.empty? + page.update(status, value: "File picker error: #{error}") + next + end + + files = Array(result) + if files.empty? + page.update(status, value: "No file selected.") + else + first = files.first || {} + name = first["name"] || first[:name] || "unknown" + path = first["path"] || first[:path] || "no-path" + page.update(status, value: "Selected: #{name} (#{path})") + end + } + ) + end + ) column( spacing: 10, children: [ status, - button( - content: text(value: "Open file picker"), - on_click: ->(_e) do - page.update(status, value: "Opening file picker...") - page.invoke( - file_picker, - "pick_files", - args: { "allow_multiple" => false, "with_data" => false }, - timeout: 600, - on_result: lambda { |result, error| - if error - page.update(status, value: "File picker error: #{error}") - next - end - - files = Array(result) - if files.empty? - page.update(status, value: "No file selected.") - next - end - - first = files.first || {} - if first.is_a?(String) - begin - first = JSON.parse(first) - rescue StandardError - first = { "name" => first, "path" => nil } - end - end - - name = first["name"] || first[:name] || "unknown" - path = first["path"] || first[:path] || "no-path" - page.update(status, value: "Selected: #{name} (#{path})") - } - ) - end - ) + open_button ] ) end diff --git a/examples/ruflet_studio/sections_media/webview.rb b/examples/ruflet_studio/sections_media/webview.rb new file mode 100644 index 00000000..af5f3043 --- /dev/null +++ b/examples/ruflet_studio/sections_media/webview.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module RufletStudio + module SectionsMedia + def build_webview(_page, _status) + webview_control = web_view( + url: "https://rubyonrails.org/", + method: "get", + expand: true + ) + container( + expand: true, + content: webview_control + ) + end + end +end diff --git a/examples/ruflet_studio/sections_minesweeper.rb b/examples/ruflet_studio/sections_minesweeper.rb index c85332fd..82777be2 100644 --- a/examples/ruflet_studio/sections_minesweeper.rb +++ b/examples/ruflet_studio/sections_minesweeper.rb @@ -47,9 +47,9 @@ def build_minesweeper(page, status) end mines_left = mine_count - mines_text = text(value: format("%03d", mines_left), style: { color: "#ff6b6b", size: 16, weight: "w600" }) + mines_text = text(value: format("%03d", mines_left), style: { size: 16, weight: "w600" }) face_text = text(value: "🙂", style: { size: 18 }) - timer_text = text(value: "000", style: { color: "#ff6b6b", size: 16, weight: "w600" }) + timer_text = text(value: "000", style: { size: 16, weight: "w600" }) cell_texts = [] cell_containers = [] @@ -68,7 +68,7 @@ def build_minesweeper(page, status) end label = sq[:flagged] ? "🚩" : "" - label_text = text(value: label, style: { size: 14, color: number_color }) + label_text = text(value: label, style: { size: 14 }) cell_texts[idx] = label_text cell_containers[idx] = container( @@ -150,7 +150,7 @@ def build_minesweeper(page, status) } end }) - safe_update.call(text, { value: label, style: { color: number_color } }) + safe_update.call(text, { value: label }) end safe_update.call(mines_text, { value: format("%03d", mines_left) }) @@ -375,8 +375,8 @@ def build_minesweeper_grid(page) height: size, left: c * size, top: r * size, - bgcolor: r.even? == c.even? ? "#2a2e36" : "#23272f", - border: { width: 1, color: "#1c1f26" } + bgcolor: r.even? == c.even? ? "#e9ecef" : "#dee2e6", + border: { width: 1, color: "#ced4da" } ) end end diff --git a/examples/ruflet_studio/sections_misc/icon_search.rb b/examples/ruflet_studio/sections_misc/icon_search.rb index d4147611..3f7a4cf1 100644 --- a/examples/ruflet_studio/sections_misc/icon_search.rb +++ b/examples/ruflet_studio/sections_misc/icon_search.rb @@ -3,13 +3,13 @@ module RufletStudio module SectionsMisc ICON_SEARCH_MAX_RESULTS = 80 + ICON_SEARCH_RESULTS_HEIGHT = 420 def build_icon_search(page, status) query = "" - summary = text(value: icon_search_summary_text(query, []), style: { size: 12, color: color_subtle(page) }) - copy_status = text(value: "Tap an item to copy icon name", style: { size: 12, color: color_subtle(page) }) + summary = text(value: icon_search_summary_text(query, []), style: { size: 12 }) + copy_status = text(value: "Tap an item to copy icon name", style: { size: 12 }) results_grid = grid_view( - expand: true, runs_count: 3, max_extent: 220, child_aspect_ratio: 2.0, @@ -23,27 +23,33 @@ def build_icon_search(page, status) names = icon_search_filtered_names(query) page.update(summary, value: icon_search_summary_text(query, names)) page.update(results_grid, controls: names.map { |name| icon_search_tile(page, name, copy_status) }) - page.update(copy_status, value: "Tap an item to copy icon name", style: { color: color_subtle(page) }) + page.update(copy_status, value: "Tap an item to copy icon name") end - column( - spacing: 10, - children: [ - status, - text_field( - label: "Search Material icons", - autofocus: true, - value: query, - on_change: ->(e) { - data = e.data - value = data.is_a?(Hash) ? (data["value"] || data[:value]) : data - on_query_change.call(value.to_s) - } - ), - summary, - copy_status, - results_grid - ] + container( + width: 760, + content: column( + spacing: 10, + children: [ + status, + text_field( + label: "Search Material icons", + autofocus: true, + value: query, + on_change: ->(e) { + data = e.data + value = data.is_a?(Hash) ? (data["value"] || data[:value]) : data + on_query_change.call(value.to_s) + } + ), + summary, + copy_status, + container( + height: ICON_SEARCH_RESULTS_HEIGHT, + content: results_grid + ) + ] + ) ) end @@ -54,9 +60,9 @@ def icon_search_tile(page, name, copy_status) on_click: ->(_e) { call_id = page.set_clipboard(name) if call_id - page.update(copy_status, value: "Copied: #{name}", style: { color: "#2b8a3e" }) + page.update(copy_status, value: "Copied: #{name}") else - page.update(copy_status, value: "Copy failed: clipboard service unavailable", style: { color: "#c92a2a" }) + page.update(copy_status, value: "Copy failed: clipboard service unavailable") end }, content: row( diff --git a/examples/ruflet_studio/views/detail_view.rb b/examples/ruflet_studio/views/detail_view.rb index 2e93bb5b..5de0d559 100644 --- a/examples/ruflet_studio/views/detail_view.rb +++ b/examples/ruflet_studio/views/detail_view.rb @@ -2,12 +2,13 @@ module RufletStudio module Views - def detail_view(page, title, content, source_path: nil) + def detail_view(page, title, content, source_path: nil, scroll: "auto", horizontal_alignment: "center", padding: 16) route = page.route control(:view, route: route, bgcolor: color_bg(page), - scroll: "auto", + scroll: scroll, + horizontal_alignment: horizontal_alignment, appbar: app_bar( bgcolor: color_surface(page), color: color_text(page), @@ -22,7 +23,7 @@ def detail_view(page, title, content, source_path: nil) end ), navigation_bar: nav_bar(page, "/gallery"), - padding: 16, + padding: padding, children: [ content ] diff --git a/examples/ruflet_studio/views/gallery_view.rb b/examples/ruflet_studio/views/gallery_view.rb index c1762f82..7137a26f 100644 --- a/examples/ruflet_studio/views/gallery_view.rb +++ b/examples/ruflet_studio/views/gallery_view.rb @@ -17,9 +17,12 @@ def gallery_view(page) ), navigation_bar: nav_bar(page, route), children: [ - column( - spacing: 6, - children: gallery_items(page) + container( + padding: { top: 12, left: 0, right: 0, bottom: 8 }, + content: column( + spacing: 6, + children: gallery_items(page) + ) ) ] ) @@ -31,6 +34,7 @@ def gallery_items(page) tile(page, "check", "To-do", "/todo"), tile(page, "calculate", "Calculator", "/calculator"), tile(page, "brush", "Drawing Tool", "/drawing"), + tile(page, "public", "WebView", "/webview"), tile(page, "view_module", "Material controls", "/material"), tile(page, "phone_iphone", "Cupertino controls", "/cupertino"), tile(page, "show_chart", "Charts", "/charts"), @@ -48,8 +52,10 @@ def gallery_items(page) def tile(page, icon, title, route) control( :list_tile, + bgcolor: color_surface(page), + content_padding: { left: 12, right: 12, top: 8, bottom: 8 }, leading: icon(icon: icon, color: color_icon(page)), - title: text(value: title, style: { color: color_text(page), size: 16 }), + title: text(value: title, style: { size: 16 }), trailing: icon(icon: "chevron_right", color: color_subtle(page)), on_click: ->(_e) { page.go(route) } ) diff --git a/examples/ruflet_studio/views/home_view.rb b/examples/ruflet_studio/views/home_view.rb index 157bbc5b..3cdc07df 100644 --- a/examples/ruflet_studio/views/home_view.rb +++ b/examples/ruflet_studio/views/home_view.rb @@ -16,8 +16,8 @@ def home_view(page) navigation_bar: nav_bar(page, route), padding: 16, children: [ - text(value: "Home", style: { size: 18, color: color_text(page) }), - text(value: "Use the Gallery tab to explore controls.", style: { color: color_subtle(page) }) + text(value: "Home", style: { size: 18 }), + text(value: "Use the Gallery tab to explore controls.") ] ) end diff --git a/examples/ruflet_studio/views/navigation_bar.rb b/examples/ruflet_studio/views/navigation_bar.rb index 01b0c627..2105fd03 100644 --- a/examples/ruflet_studio/views/navigation_bar.rb +++ b/examples/ruflet_studio/views/navigation_bar.rb @@ -11,10 +11,12 @@ def nav_bar(page, route) navigation_bar( bgcolor: color_surface(page), - indicator_color: effective_theme(page) == "light" ? "#dbe4ff" : "#2b3036", + indicator_color: color_nav_indicator(page), selected_index: selected, on_change: ->(e) { idx = read_number(e.data, "selected_index") || read_number(e.data, "selectedIndex") + next if idx.nil? || idx.to_i == selected + case idx&.to_i when 0 page.go("/home") diff --git a/examples/ruflet_studio/views/settings_view.rb b/examples/ruflet_studio/views/settings_view.rb index 9160bf2f..25f1f742 100644 --- a/examples/ruflet_studio/views/settings_view.rb +++ b/examples/ruflet_studio/views/settings_view.rb @@ -24,14 +24,18 @@ def settings_view(page) column( spacing: 16, children: [ - text(value: "Theme", style: { size: 14, color: color_icon(page) }), + text(value: "Theme", style: { size: 14 }), radio_group( value: theme_mode, on_change: ->(e) { - value = read_string(e.data, "value") || read_string(e.data, "selected") || e.data.to_s + value = + read_string(e.data, "value") || + read_string(e.data, :value) || + read_string(e.data, "selected") || + read_string(e.data, :selected) + next unless %w[system light dark].include?(value) + set_theme(page, value) - page.views = [settings_view(page)] - page.update }, content: column( spacing: 14, @@ -43,7 +47,7 @@ def settings_view(page) spacing: 12, children: [ icon(icon: "contrast", color: color_icon(page)), - text(value: "System", style: { color: color_text(page) }) + text(value: "System") ] ), radio(value: "system") @@ -56,7 +60,7 @@ def settings_view(page) spacing: 12, children: [ icon(icon: "light_mode", color: color_icon(page)), - text(value: "Light", style: { color: color_text(page) }) + text(value: "Light") ] ), radio(value: "light") @@ -69,7 +73,7 @@ def settings_view(page) spacing: 12, children: [ icon(icon: "dark_mode", color: color_icon(page)), - text(value: "Dark", style: { color: color_text(page) }) + text(value: "Dark") ] ), radio(value: "dark") @@ -79,11 +83,11 @@ def settings_view(page) ) ), container(height: 1, bgcolor: color_divider(page), margin: { top: 8, bottom: 8 }), - text(value: "Home gestures", style: { size: 14, color: color_icon(page) }), + text(value: "Home gestures", style: { size: 14 }), control( :list_tile, leading: icon(icon: "vibration", color: color_icon(page)), - title: text(value: "Shake device", style: { color: color_text(page) }), + title: text(value: "Shake device"), trailing: gestures_shake, on_click: ->(_e) { gestures_shake_state = !gestures_shake_state @@ -93,7 +97,7 @@ def settings_view(page) control( :list_tile, leading: icon(icon: "pan_tool_alt", color: color_icon(page)), - title: text(value: "Long press with two fingers", style: { color: color_text(page) }), + title: text(value: "Long press with two fingers"), trailing: gestures_long_press, on_click: ->(_e) { gestures_long_press_state = !gestures_long_press_state @@ -101,26 +105,26 @@ def settings_view(page) } ), container(height: 1, bgcolor: color_divider(page), margin: { top: 8, bottom: 8 }), - text(value: "Application details", style: { size: 14, color: color_icon(page) }), + text(value: "Application details", style: { size: 14 }), row( alignment: "spaceBetween", children: [ - text(value: "Client version:", style: { color: color_text(page) }), - text(value: "#{Ruflet::VERSION}", style: { color: color_subtle(page) }) + text(value: "Client version:"), + text(value: "#{Ruflet::VERSION}") ] ), row( alignment: "spaceBetween", children: [ - text(value: "Ruflet SDK version:", style: { color: color_text(page) }), - text(value: "#{Ruflet::VERSION}", style: { color: color_subtle(page) }) + text(value: "Ruflet SDK version:"), + text(value: "#{Ruflet::VERSION}") ] ), row( alignment: "spaceBetween", children: [ - text(value: "Ruby version:", style: { color: color_text(page) }), - text(value: "#{RUBY_VERSION}", style: { color: color_subtle(page) }) + text(value: "Ruby version:"), + text(value: "#{RUBY_VERSION}") ] ) ] diff --git a/examples/ruflet_studio/views/status_text.rb b/examples/ruflet_studio/views/status_text.rb index 95af2f25..ff85437d 100644 --- a/examples/ruflet_studio/views/status_text.rb +++ b/examples/ruflet_studio/views/status_text.rb @@ -2,8 +2,8 @@ module RufletStudio module Views - def status_text(page) - text(value: "Ready", style: { size: 12, color: color_subtle(page) }) + def status_text(_page) + text(value: "", style: { size: 12 }) end end end diff --git a/packages/ruflet/lib/ruflet_ui.rb b/packages/ruflet/lib/ruflet_ui.rb index 9f800b1e..d1a44f51 100644 --- a/packages/ruflet/lib/ruflet_ui.rb +++ b/packages/ruflet/lib/ruflet_ui.rb @@ -79,7 +79,7 @@ def [](name) class << self include UI::SharedControlForwarders - def app(host: "0.0.0.0", port: 8550, &block) + def app(host: nil, port: nil, &block) DSL.app(host: host, port: port, &block) end diff --git a/packages/ruflet/lib/ruflet_ui/ruflet/app.rb b/packages/ruflet/lib/ruflet_ui/ruflet/app.rb index c7917901..2469312e 100644 --- a/packages/ruflet/lib/ruflet_ui/ruflet/app.rb +++ b/packages/ruflet/lib/ruflet_ui/ruflet/app.rb @@ -2,9 +2,9 @@ module Ruflet class App - def initialize(host: "0.0.0.0", port: 8550) - @host = host - @port = port + def initialize(host: nil, port: nil) + @host = (host || ENV["RUFLET_HOST"] || "0.0.0.0") + @port = normalize_port(port || ENV["RUFLET_PORT"] || 8550) end def run @@ -16,5 +16,12 @@ def run def view(_page) raise NotImplementedError, "#{self.class} must implement #view(page)" end + + private + + def normalize_port(value) + port = value.to_i + port > 0 ? port : 8550 + end end end diff --git a/packages/ruflet/lib/ruflet_ui/ruflet/dsl.rb b/packages/ruflet/lib/ruflet_ui/ruflet/dsl.rb index d04396d9..a7a1bb63 100644 --- a/packages/ruflet/lib/ruflet_ui/ruflet/dsl.rb +++ b/packages/ruflet/lib/ruflet_ui/ruflet/dsl.rb @@ -16,15 +16,27 @@ module DSL module_function + def default_host + ENV["RUFLET_HOST"].to_s.strip.empty? ? "0.0.0.0" : ENV["RUFLET_HOST"].to_s + end + + def default_port + raw = ENV["RUFLET_PORT"].to_s + value = raw.to_i + value > 0 ? value : 8550 + end + def _pending_app - @_pending_app ||= App.new(host: "0.0.0.0", port: 8550) + @_pending_app ||= App.new(host: default_host, port: default_port) end def _reset_pending_app! - @_pending_app = App.new(host: "0.0.0.0", port: 8550) + @_pending_app = App.new(host: default_host, port: default_port) end - def app(host: "0.0.0.0", port: 8550, &block) + def app(host: nil, port: nil, &block) + host ||= default_host + port ||= default_port return App.new(host: host, port: port).tap { |a| a.instance_eval(&block) } if block pending = _pending_app @@ -52,7 +64,6 @@ def dragtarget(**props, &block) = _pending_app.dragtarget(**props, &block) def text(value = nil, **props) = _pending_app.text(value, **props) def button(**props) = _pending_app.button(**props) def elevated_button(**props) = _pending_app.elevated_button(**props) - def elevatedbutton(**props) = _pending_app.elevatedbutton(**props) def text_field(**props) = _pending_app.text_field(**props) def textfield(**props) = _pending_app.textfield(**props) def icon(**props) = _pending_app.icon(**props) @@ -127,6 +138,8 @@ def chart_axis(**props) = _pending_app.chart_axis(**props) def chartaxis(**props) = _pending_app.chartaxis(**props) def chart_axis_label(**props) = _pending_app.chart_axis_label(**props) def chartaxislabel(**props) = _pending_app.chartaxislabel(**props) + def web_view(**props) = _pending_app.web_view(**props) + def webview(**props) = _pending_app.webview(**props) def fab(content = nil, **props) = _pending_app.fab(content, **props) def cupertino_button(**props) = _pending_app.cupertino_button(**props) def cupertinobutton(**props) = _pending_app.cupertinobutton(**props) diff --git a/packages/ruflet/lib/ruflet_ui/ruflet/page.rb b/packages/ruflet/lib/ruflet_ui/ruflet/page.rb index 0d6dc7b4..ee48f003 100644 --- a/packages/ruflet/lib/ruflet_ui/ruflet/page.rb +++ b/packages/ruflet/lib/ruflet_ui/ruflet/page.rb @@ -284,9 +284,10 @@ def invoke_sync(control_or_id, method_name, args: nil, timeout: 10) end def launch_url(url, mode: "external_application", web_view_configuration: nil, browser_configuration: nil, web_only_window_name: nil, timeout: 10) + url_launcher = ensure_url_launcher_service invoke( - 1, - "launchUrl", + url_launcher, + "launch_url", args: { "url" => url, "mode" => mode, @@ -299,7 +300,8 @@ def launch_url(url, mode: "external_application", web_view_configuration: nil, b end def can_launch_url(url, timeout: 10) - invoke(1, "canLaunchUrl", args: { "url" => url }, timeout: timeout) + url_launcher = ensure_url_launcher_service + invoke(url_launcher, "can_launch_url", args: { "url" => url }, timeout: timeout) end def set_clipboard(value, timeout: 10) @@ -374,6 +376,13 @@ def update(control_or_id = nil, **props) control = resolve_control(control_or_id) return self unless control + wire_id = control.wire_id + if wire_id.nil? + # Events can race with navigation/disposal; never emit patch_control with nil id. + refresh_control_indexes! + wire_id = control.wire_id + end + return self if wire_id.nil? patch = normalize_props(props) if text_maps_to_content?(control, patch) @@ -392,7 +401,7 @@ def update(control_or_id = nil, **props) patch_ops = patch.map { |k, v| [0, 0, k, serialize_patch_value(v)] } send_message(Protocol::ACTIONS[:patch_control], { - "id" => control.wire_id, + "id" => wire_id, "patch" => [[0], *patch_ops] }) @@ -781,5 +790,14 @@ def ensure_clipboard_service add_service(clipboard) clipboard end + + def ensure_url_launcher_service + url_launcher = services.find { |service| service.is_a?(Control) && %w[urllauncher url_launcher].include?(service.type) } + return url_launcher if url_launcher + + url_launcher = build_widget(:url_launcher) + add_service(url_launcher) + url_launcher + end end end diff --git a/packages/ruflet/lib/ruflet_ui/ruflet/ui/controls/materials/audio_control.rb b/packages/ruflet/lib/ruflet_ui/ruflet/ui/controls/materials/audio_control.rb new file mode 100644 index 00000000..0e82cb56 --- /dev/null +++ b/packages/ruflet/lib/ruflet_ui/ruflet/ui/controls/materials/audio_control.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Ruflet + module UI + module Controls + module RufletComponents + class AudioControl < Ruflet::Control + TYPE = "audio".freeze + WIRE = "Audio".freeze + + def initialize(id: nil, autoplay: nil, balance: nil, data: nil, key: nil, opacity: nil, release_mode: nil, rtl: nil, src: nil, tooltip: nil, visible: nil, volume: nil, on_duration_change: nil, on_error: nil, on_loaded: nil, on_position_change: nil, on_seek_complete: nil, on_state_change: nil) + props = {} + props[:autoplay] = autoplay unless autoplay.nil? + props[:balance] = balance unless balance.nil? + props[:data] = data unless data.nil? + props[:key] = key unless key.nil? + props[:opacity] = opacity unless opacity.nil? + props[:release_mode] = release_mode unless release_mode.nil? + props[:rtl] = rtl unless rtl.nil? + props[:src] = src unless src.nil? + props[:tooltip] = tooltip unless tooltip.nil? + props[:visible] = visible unless visible.nil? + props[:volume] = volume unless volume.nil? + props[:on_duration_change] = on_duration_change unless on_duration_change.nil? + props[:on_error] = on_error unless on_error.nil? + props[:on_loaded] = on_loaded unless on_loaded.nil? + props[:on_position_change] = on_position_change unless on_position_change.nil? + props[:on_seek_complete] = on_seek_complete unless on_seek_complete.nil? + props[:on_state_change] = on_state_change unless on_state_change.nil? + super(type: TYPE, id: id, **props) + end + end + end + end + end +end diff --git a/packages/ruflet/lib/ruflet_ui/ruflet/ui/controls/materials/chart_controls.rb b/packages/ruflet/lib/ruflet_ui/ruflet/ui/controls/materials/chart_controls.rb new file mode 100644 index 00000000..cd9d1c5e --- /dev/null +++ b/packages/ruflet/lib/ruflet_ui/ruflet/ui/controls/materials/chart_controls.rb @@ -0,0 +1,321 @@ +# frozen_string_literal: true + +module Ruflet + module UI + module Controls + module RufletComponents + class ChartAxisControl < Ruflet::Control + TYPE = "chartaxis".freeze + WIRE = "axis".freeze + + def initialize(id: nil, title: nil, labels: nil, label_size: nil, title_size: nil, show_labels: nil) + props = {} + props[:title] = title unless title.nil? + props[:labels] = labels unless labels.nil? + props[:label_size] = label_size unless label_size.nil? + props[:title_size] = title_size unless title_size.nil? + props[:show_labels] = show_labels unless show_labels.nil? + super(type: TYPE, id: id, **props) + end + end + + class ChartAxisLabelControl < Ruflet::Control + TYPE = "chartaxislabel".freeze + WIRE = "l".freeze + + def initialize(id: nil, value: nil, label: nil) + props = {} + props[:value] = value unless value.nil? + props[:label] = label unless label.nil? + super(type: TYPE, id: id, **props) + end + end + + class BarChartControl < Ruflet::Control + TYPE = "barchart".freeze + WIRE = "BarChart".freeze + + def initialize(id: nil, width: nil, height: nil, min_y: nil, max_y: nil, min_x: nil, max_x: nil, groups: nil, left_axis: nil, right_axis: nil, top_axis: nil, bottom_axis: nil, horizontal_grid_lines: nil, vertical_grid_lines: nil, border: nil, tooltip: nil, on_event: nil) + props = {} + props[:width] = width unless width.nil? + props[:height] = height unless height.nil? + props[:min_y] = min_y unless min_y.nil? + props[:max_y] = max_y unless max_y.nil? + props[:min_x] = min_x unless min_x.nil? + props[:max_x] = max_x unless max_x.nil? + props[:groups] = groups unless groups.nil? + props[:left_axis] = left_axis unless left_axis.nil? + props[:right_axis] = right_axis unless right_axis.nil? + props[:top_axis] = top_axis unless top_axis.nil? + props[:bottom_axis] = bottom_axis unless bottom_axis.nil? + props[:horizontal_grid_lines] = horizontal_grid_lines unless horizontal_grid_lines.nil? + props[:vertical_grid_lines] = vertical_grid_lines unless vertical_grid_lines.nil? + props[:border] = border unless border.nil? + props[:tooltip] = tooltip unless tooltip.nil? + props[:on_event] = on_event unless on_event.nil? + super(type: TYPE, id: id, **props) + end + end + + class BarChartGroupControl < Ruflet::Control + TYPE = "barchartgroup".freeze + WIRE = "group".freeze + + def initialize(id: nil, x: nil, rods: nil, bars_space: nil, showing_tooltip_indicators: nil) + props = {} + props[:x] = x unless x.nil? + props[:rods] = rods unless rods.nil? + props[:bars_space] = bars_space unless bars_space.nil? + props[:showing_tooltip_indicators] = showing_tooltip_indicators unless showing_tooltip_indicators.nil? + super(type: TYPE, id: id, **props) + end + end + + class BarChartRodControl < Ruflet::Control + TYPE = "barchartrod".freeze + WIRE = "rod".freeze + + def initialize(id: nil, from_y: nil, to_y: nil, width: nil, color: nil, gradient: nil, border_radius: nil, rod_stack_items: nil) + props = {} + props[:from_y] = from_y unless from_y.nil? + props[:to_y] = to_y unless to_y.nil? + props[:width] = width unless width.nil? + props[:color] = color unless color.nil? + props[:gradient] = gradient unless gradient.nil? + props[:border_radius] = border_radius unless border_radius.nil? + props[:rod_stack_items] = rod_stack_items unless rod_stack_items.nil? + super(type: TYPE, id: id, **props) + end + end + + class BarChartRodStackItemControl < Ruflet::Control + TYPE = "barchartrodstackitem".freeze + WIRE = "stack_item".freeze + + def initialize(id: nil, from_y: nil, to_y: nil, color: nil, border_side: nil) + props = {} + props[:from_y] = from_y unless from_y.nil? + props[:to_y] = to_y unless to_y.nil? + props[:color] = color unless color.nil? + props[:border_side] = border_side unless border_side.nil? + super(type: TYPE, id: id, **props) + end + end + + class LineChartControl < Ruflet::Control + TYPE = "linechart".freeze + WIRE = "LineChart".freeze + + def initialize(id: nil, width: nil, height: nil, min_y: nil, max_y: nil, min_x: nil, max_x: nil, data_series: nil, left_axis: nil, right_axis: nil, top_axis: nil, bottom_axis: nil, interactive: nil, tooltip: nil, on_event: nil) + props = {} + props[:width] = width unless width.nil? + props[:height] = height unless height.nil? + props[:min_y] = min_y unless min_y.nil? + props[:max_y] = max_y unless max_y.nil? + props[:min_x] = min_x unless min_x.nil? + props[:max_x] = max_x unless max_x.nil? + props[:data_series] = data_series unless data_series.nil? + props[:left_axis] = left_axis unless left_axis.nil? + props[:right_axis] = right_axis unless right_axis.nil? + props[:top_axis] = top_axis unless top_axis.nil? + props[:bottom_axis] = bottom_axis unless bottom_axis.nil? + props[:interactive] = interactive unless interactive.nil? + props[:tooltip] = tooltip unless tooltip.nil? + props[:on_event] = on_event unless on_event.nil? + super(type: TYPE, id: id, **props) + end + end + + class LineChartDataControl < Ruflet::Control + TYPE = "linechartdata".freeze + WIRE = "data".freeze + + def initialize(id: nil, points: nil, color: nil, gradient: nil, stroke_width: nil, curved: nil, rounded_stroke_cap: nil) + props = {} + props[:points] = points unless points.nil? + props[:color] = color unless color.nil? + props[:gradient] = gradient unless gradient.nil? + props[:stroke_width] = stroke_width unless stroke_width.nil? + props[:curved] = curved unless curved.nil? + props[:rounded_stroke_cap] = rounded_stroke_cap unless rounded_stroke_cap.nil? + super(type: TYPE, id: id, **props) + end + end + + class LineChartDataPointControl < Ruflet::Control + TYPE = "linechartdatapoint".freeze + WIRE = "p".freeze + + def initialize(id: nil, x: nil, y: nil) + props = {} + props[:x] = x unless x.nil? + props[:y] = y unless y.nil? + super(type: TYPE, id: id, **props) + end + end + + class PieChartControl < Ruflet::Control + TYPE = "piechart".freeze + WIRE = "PieChart".freeze + + def initialize(id: nil, width: nil, height: nil, sections: nil, sections_space: nil, center_space_radius: nil, tooltip: nil, on_event: nil) + props = {} + props[:width] = width unless width.nil? + props[:height] = height unless height.nil? + props[:sections] = sections unless sections.nil? + props[:sections_space] = sections_space unless sections_space.nil? + props[:center_space_radius] = center_space_radius unless center_space_radius.nil? + props[:tooltip] = tooltip unless tooltip.nil? + props[:on_event] = on_event unless on_event.nil? + super(type: TYPE, id: id, **props) + end + end + + class PieChartSectionControl < Ruflet::Control + TYPE = "piechartsection".freeze + WIRE = "section".freeze + + def initialize(id: nil, value: nil, title: nil, color: nil, radius: nil, title_style: nil, badge_widget: nil, badge_position_percentage_offset: nil) + props = {} + props[:value] = value unless value.nil? + props[:title] = title unless title.nil? + props[:color] = color unless color.nil? + props[:radius] = radius unless radius.nil? + props[:title_style] = title_style unless title_style.nil? + props[:badge_widget] = badge_widget unless badge_widget.nil? + props[:badge_position_percentage_offset] = badge_position_percentage_offset unless badge_position_percentage_offset.nil? + super(type: TYPE, id: id, **props) + end + end + + class CandlestickChartControl < Ruflet::Control + TYPE = "candlestickchart".freeze + WIRE = "CandlestickChart".freeze + + def initialize(id: nil, width: nil, height: nil, min_x: nil, max_x: nil, min_y: nil, max_y: nil, spots: nil, left_axis: nil, right_axis: nil, top_axis: nil, bottom_axis: nil, tooltip: nil, on_event: nil) + props = {} + props[:width] = width unless width.nil? + props[:height] = height unless height.nil? + props[:min_x] = min_x unless min_x.nil? + props[:max_x] = max_x unless max_x.nil? + props[:min_y] = min_y unless min_y.nil? + props[:max_y] = max_y unless max_y.nil? + props[:spots] = spots unless spots.nil? + props[:left_axis] = left_axis unless left_axis.nil? + props[:right_axis] = right_axis unless right_axis.nil? + props[:top_axis] = top_axis unless top_axis.nil? + props[:bottom_axis] = bottom_axis unless bottom_axis.nil? + props[:tooltip] = tooltip unless tooltip.nil? + props[:on_event] = on_event unless on_event.nil? + super(type: TYPE, id: id, **props) + end + end + + class CandlestickChartSpotControl < Ruflet::Control + TYPE = "candlestickchartspot".freeze + WIRE = "CandlestickChartSpot".freeze + + def initialize(id: nil, x: nil, open: nil, high: nil, low: nil, close: nil, selected: nil) + props = {} + props[:x] = x unless x.nil? + props[:open] = open unless open.nil? + props[:high] = high unless high.nil? + props[:low] = low unless low.nil? + props[:close] = close unless close.nil? + props[:selected] = selected unless selected.nil? + super(type: TYPE, id: id, **props) + end + end + + class RadarChartControl < Ruflet::Control + TYPE = "radarchart".freeze + WIRE = "RadarChart".freeze + + def initialize(id: nil, width: nil, height: nil, titles: nil, data_sets: nil, on_event: nil) + props = {} + props[:width] = width unless width.nil? + props[:height] = height unless height.nil? + props[:titles] = titles unless titles.nil? + props[:data_sets] = data_sets unless data_sets.nil? + props[:on_event] = on_event unless on_event.nil? + super(type: TYPE, id: id, **props) + end + end + + class RadarChartTitleControl < Ruflet::Control + TYPE = "radarcharttitle".freeze + WIRE = "RadarChartTitle".freeze + + def initialize(id: nil, text: nil, angle: nil, position_percentage_offset: nil) + props = {} + props[:text] = text unless text.nil? + props[:angle] = angle unless angle.nil? + props[:position_percentage_offset] = position_percentage_offset unless position_percentage_offset.nil? + super(type: TYPE, id: id, **props) + end + end + + class RadarDataSetControl < Ruflet::Control + TYPE = "radardataset".freeze + WIRE = "RadarDataSet".freeze + + def initialize(id: nil, entries: nil, border_color: nil, fill_color: nil, border_width: nil) + props = {} + props[:entries] = entries unless entries.nil? + props[:border_color] = border_color unless border_color.nil? + props[:fill_color] = fill_color unless fill_color.nil? + props[:border_width] = border_width unless border_width.nil? + super(type: TYPE, id: id, **props) + end + end + + class RadarDataSetEntryControl < Ruflet::Control + TYPE = "radardatasetentry".freeze + WIRE = "RadarDataSetEntry".freeze + + def initialize(id: nil, value: nil) + props = {} + props[:value] = value unless value.nil? + super(type: TYPE, id: id, **props) + end + end + + class ScatterChartControl < Ruflet::Control + TYPE = "scatterchart".freeze + WIRE = "ScatterChart".freeze + + def initialize(id: nil, width: nil, height: nil, min_x: nil, max_x: nil, min_y: nil, max_y: nil, spots: nil, left_axis: nil, right_axis: nil, top_axis: nil, bottom_axis: nil, on_event: nil) + props = {} + props[:width] = width unless width.nil? + props[:height] = height unless height.nil? + props[:min_x] = min_x unless min_x.nil? + props[:max_x] = max_x unless max_x.nil? + props[:min_y] = min_y unless min_y.nil? + props[:max_y] = max_y unless max_y.nil? + props[:spots] = spots unless spots.nil? + props[:left_axis] = left_axis unless left_axis.nil? + props[:right_axis] = right_axis unless right_axis.nil? + props[:top_axis] = top_axis unless top_axis.nil? + props[:bottom_axis] = bottom_axis unless bottom_axis.nil? + props[:on_event] = on_event unless on_event.nil? + super(type: TYPE, id: id, **props) + end + end + + class ScatterChartSpotControl < Ruflet::Control + TYPE = "scatterchartspot".freeze + WIRE = "ScatterChartSpot".freeze + + def initialize(id: nil, x: nil, y: nil, radius: nil, color: nil) + props = {} + props[:x] = x unless x.nil? + props[:y] = y unless y.nil? + props[:radius] = radius unless radius.nil? + props[:color] = color unless color.nil? + super(type: TYPE, id: id, **props) + end + end + end + end + end +end diff --git a/packages/ruflet/lib/ruflet_ui/ruflet/ui/controls/materials/ruflet_controls.rb b/packages/ruflet/lib/ruflet_ui/ruflet/ui/controls/materials/ruflet_controls.rb index 56ea78c8..7e503717 100644 --- a/packages/ruflet/lib/ruflet_ui/ruflet/ui/controls/materials/ruflet_controls.rb +++ b/packages/ruflet/lib/ruflet_ui/ruflet/ui/controls/materials/ruflet_controls.rb @@ -1,10 +1,12 @@ # frozen_string_literal: true require_relative "alertdialog_control" +require_relative "audio_control" require_relative "appbar_control" require_relative "autocomplete_control" require_relative "badge_control" require_relative "banner_control" +require_relative "chart_controls" require_relative "bottomappbar_control" require_relative "bottomsheet_control" require_relative "button_control" @@ -69,6 +71,7 @@ require_relative "textfield_control" require_relative "timepicker_control" require_relative "verticaldivider_control" +require_relative "webview_control" module Ruflet module UI @@ -80,6 +83,7 @@ module RufletControls CLASS_MAP = { "alert_dialog" => RufletComponents::AlertDialogControl, "alertdialog" => RufletComponents::AlertDialogControl, + "audio" => RufletComponents::AudioControl, "app_bar" => RufletComponents::AppBarControl, "appbar" => RufletComponents::AppBarControl, "auto_complete" => RufletComponents::AutoCompleteControl, @@ -90,8 +94,24 @@ module RufletControls "bottom_sheet" => RufletComponents::BottomSheetControl, "bottomappbar" => RufletComponents::BottomAppBarControl, "bottomsheet" => RufletComponents::BottomSheetControl, + "bar_chart" => RufletComponents::BarChartControl, + "bar_chart_group" => RufletComponents::BarChartGroupControl, + "bar_chart_rod" => RufletComponents::BarChartRodControl, + "bar_chart_rod_stack_item" => RufletComponents::BarChartRodStackItemControl, + "barchart" => RufletComponents::BarChartControl, + "barchartgroup" => RufletComponents::BarChartGroupControl, + "barchartrod" => RufletComponents::BarChartRodControl, + "barchartrodstackitem" => RufletComponents::BarChartRodStackItemControl, "button" => RufletComponents::ButtonControl, "card" => RufletComponents::CardControl, + "candlestick_chart" => RufletComponents::CandlestickChartControl, + "candlestick_chart_spot" => RufletComponents::CandlestickChartSpotControl, + "candlestickchart" => RufletComponents::CandlestickChartControl, + "candlestickchartspot" => RufletComponents::CandlestickChartSpotControl, + "chart_axis" => RufletComponents::ChartAxisControl, + "chart_axis_label" => RufletComponents::ChartAxisLabelControl, + "chartaxis" => RufletComponents::ChartAxisControl, + "chartaxislabel" => RufletComponents::ChartAxisLabelControl, "checkbox" => RufletComponents::CheckboxControl, "chip" => RufletComponents::ChipControl, "circle_avatar" => RufletComponents::CircleAvatarControl, @@ -135,6 +155,12 @@ module RufletControls "floatingactionbutton" => RufletComponents::FloatingActionButtonControl, "icon_button" => RufletComponents::IconButtonControl, "iconbutton" => RufletComponents::IconButtonControl, + "line_chart" => RufletComponents::LineChartControl, + "line_chart_data" => RufletComponents::LineChartDataControl, + "line_chart_data_point" => RufletComponents::LineChartDataPointControl, + "linechart" => RufletComponents::LineChartControl, + "linechartdata" => RufletComponents::LineChartDataControl, + "linechartdatapoint" => RufletComponents::LineChartDataPointControl, "list_tile" => RufletComponents::ListTileControl, "listtile" => RufletComponents::ListTileControl, "menu_bar" => RufletComponents::MenuBarControl, @@ -162,6 +188,10 @@ module RufletControls "popup_menu_item" => RufletComponents::PopupMenuItemControl, "popupmenubutton" => RufletComponents::PopupMenuButtonControl, "popupmenuitem" => RufletComponents::PopupMenuItemControl, + "pie_chart" => RufletComponents::PieChartControl, + "pie_chart_section" => RufletComponents::PieChartSectionControl, + "piechart" => RufletComponents::PieChartControl, + "piechartsection" => RufletComponents::PieChartSectionControl, "progress_bar" => RufletComponents::ProgressBarControl, "progress_ring" => RufletComponents::ProgressRingControl, "progressbar" => RufletComponents::ProgressBarControl, @@ -169,6 +199,14 @@ module RufletControls "radio" => RufletComponents::RadioControl, "radio_group" => RufletComponents::RadioGroupControl, "radiogroup" => RufletComponents::RadioGroupControl, + "radar_chart" => RufletComponents::RadarChartControl, + "radar_chart_title" => RufletComponents::RadarChartTitleControl, + "radar_data_set" => RufletComponents::RadarDataSetControl, + "radar_data_set_entry" => RufletComponents::RadarDataSetEntryControl, + "radarchart" => RufletComponents::RadarChartControl, + "radarcharttitle" => RufletComponents::RadarChartTitleControl, + "radardataset" => RufletComponents::RadarDataSetControl, + "radardatasetentry" => RufletComponents::RadarDataSetEntryControl, "range_slider" => RufletComponents::RangeSliderControl, "rangeslider" => RufletComponents::RangeSliderControl, "reorderable_list_view" => RufletComponents::ReorderableListViewControl, @@ -180,6 +218,10 @@ module RufletControls "segmentedbutton" => RufletComponents::SegmentedButtonControl, "selection_area" => RufletComponents::SelectionAreaControl, "selectionarea" => RufletComponents::SelectionAreaControl, + "scatter_chart" => RufletComponents::ScatterChartControl, + "scatter_chart_spot" => RufletComponents::ScatterChartSpotControl, + "scatterchart" => RufletComponents::ScatterChartControl, + "scatterchartspot" => RufletComponents::ScatterChartSpotControl, "slider" => RufletComponents::SliderControl, "snack_bar" => RufletComponents::SnackBarControl, "snackbar" => RufletComponents::SnackBarControl, @@ -200,6 +242,8 @@ module RufletControls "timepicker" => RufletComponents::TimePickerControl, "vertical_divider" => RufletComponents::VerticalDividerControl, "verticaldivider" => RufletComponents::VerticalDividerControl, + "web_view" => RufletComponents::WebViewControl, + "webview" => RufletComponents::WebViewControl, }.freeze end end diff --git a/packages/ruflet/lib/ruflet_ui/ruflet/ui/controls/materials/webview_control.rb b/packages/ruflet/lib/ruflet_ui/ruflet/ui/controls/materials/webview_control.rb new file mode 100644 index 00000000..8145072e --- /dev/null +++ b/packages/ruflet/lib/ruflet_ui/ruflet/ui/controls/materials/webview_control.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Ruflet + module UI + module Controls + module RufletComponents + class WebViewControl < Ruflet::Control + TYPE = "WebView".freeze + WIRE = "WebView".freeze + + def initialize(id: nil, bgcolor: nil, data: nil, enable_javascript: nil, expand: nil, height: nil, key: nil, method: nil, opacity: nil, rtl: nil, tooltip: nil, url: nil, visible: nil, width: nil, on_page_ended: nil, on_page_started: nil, on_web_resource_error: nil) + props = {} + props[:bgcolor] = bgcolor unless bgcolor.nil? + props[:data] = data unless data.nil? + props[:enable_javascript] = enable_javascript unless enable_javascript.nil? + props[:expand] = expand unless expand.nil? + props[:height] = height unless height.nil? + props[:key] = key unless key.nil? + props[:method] = method unless method.nil? + props[:opacity] = opacity unless opacity.nil? + props[:rtl] = rtl unless rtl.nil? + props[:tooltip] = tooltip unless tooltip.nil? + props[:url] = url unless url.nil? + props[:visible] = visible unless visible.nil? + props[:width] = width unless width.nil? + props[:on_page_ended] = on_page_ended unless on_page_ended.nil? + props[:on_page_started] = on_page_started unless on_page_started.nil? + props[:on_web_resource_error] = on_web_resource_error unless on_web_resource_error.nil? + super(type: TYPE, id: id, **props) + end + end + end + end + end +end diff --git a/packages/ruflet/lib/ruflet_ui/ruflet/ui/controls/ruflet_controls.rb b/packages/ruflet/lib/ruflet_ui/ruflet/ui/controls/ruflet_controls.rb index 7d74f131..906f68d4 100644 --- a/packages/ruflet/lib/ruflet_ui/ruflet/ui/controls/ruflet_controls.rb +++ b/packages/ruflet/lib/ruflet_ui/ruflet/ui/controls/ruflet_controls.rb @@ -25,10 +25,12 @@ require_relative "cupertinos/cupertinotimerpicker_control" require_relative "cupertinos/cupertinotintedbutton_control" require_relative "materials/alertdialog_control" +require_relative "materials/audio_control" require_relative "materials/appbar_control" require_relative "materials/autocomplete_control" require_relative "materials/badge_control" require_relative "materials/banner_control" +require_relative "materials/chart_controls" require_relative "materials/bottomappbar_control" require_relative "materials/bottomsheet_control" require_relative "materials/button_control" @@ -94,6 +96,7 @@ require_relative "materials/timepicker_control" require_relative "materials/verticaldivider_control" require_relative "materials/video_control" +require_relative "materials/webview_control" require_relative "shared/animatedswitcher_control" require_relative "shared/arc_control" require_relative "shared/autofillgroup_control" @@ -155,6 +158,7 @@ module RufletControls CLASS_MAP = { "alert_dialog" => RufletComponents::AlertDialogControl, "alertdialog" => RufletComponents::AlertDialogControl, + "audio" => RufletComponents::AudioControl, "animated_switcher" => RufletComponents::AnimatedSwitcherControl, "animatedswitcher" => RufletComponents::AnimatedSwitcherControl, "app_bar" => RufletComponents::AppBarControl, @@ -172,11 +176,27 @@ module RufletControls "bottom_sheet" => RufletComponents::BottomSheetControl, "bottomappbar" => RufletComponents::BottomAppBarControl, "bottomsheet" => RufletComponents::BottomSheetControl, + "bar_chart" => RufletComponents::BarChartControl, + "bar_chart_group" => RufletComponents::BarChartGroupControl, + "bar_chart_rod" => RufletComponents::BarChartRodControl, + "bar_chart_rod_stack_item" => RufletComponents::BarChartRodStackItemControl, + "barchart" => RufletComponents::BarChartControl, + "barchartgroup" => RufletComponents::BarChartGroupControl, + "barchartrod" => RufletComponents::BarChartRodControl, + "barchartrodstackitem" => RufletComponents::BarChartRodStackItemControl, "browser_context_menu" => RufletComponents::BrowserContextMenuControl, "browsercontextmenu" => RufletComponents::BrowserContextMenuControl, "button" => RufletComponents::ButtonControl, "canvas" => RufletComponents::CanvasControl, "card" => RufletComponents::CardControl, + "candlestick_chart" => RufletComponents::CandlestickChartControl, + "candlestick_chart_spot" => RufletComponents::CandlestickChartSpotControl, + "candlestickchart" => RufletComponents::CandlestickChartControl, + "candlestickchartspot" => RufletComponents::CandlestickChartSpotControl, + "chart_axis" => RufletComponents::ChartAxisControl, + "chart_axis_label" => RufletComponents::ChartAxisLabelControl, + "chartaxis" => RufletComponents::ChartAxisControl, + "chartaxislabel" => RufletComponents::ChartAxisLabelControl, "checkbox" => RufletComponents::CheckboxControl, "chip" => RufletComponents::ChipControl, "circle" => RufletComponents::CircleControl, @@ -291,6 +311,12 @@ module RufletControls "keyboard_listener" => RufletComponents::KeyboardListenerControl, "keyboardlistener" => RufletComponents::KeyboardListenerControl, "line" => RufletComponents::LineControl, + "line_chart" => RufletComponents::LineChartControl, + "line_chart_data" => RufletComponents::LineChartDataControl, + "line_chart_data_point" => RufletComponents::LineChartDataPointControl, + "linechart" => RufletComponents::LineChartControl, + "linechartdata" => RufletComponents::LineChartDataControl, + "linechartdatapoint" => RufletComponents::LineChartDataPointControl, "list_tile" => RufletComponents::ListTileControl, "list_view" => RufletComponents::ListViewControl, "listtile" => RufletComponents::ListTileControl, @@ -332,6 +358,10 @@ module RufletControls "popup_menu_item" => RufletComponents::PopupMenuItemControl, "popupmenubutton" => RufletComponents::PopupMenuButtonControl, "popupmenuitem" => RufletComponents::PopupMenuItemControl, + "pie_chart" => RufletComponents::PieChartControl, + "pie_chart_section" => RufletComponents::PieChartSectionControl, + "piechart" => RufletComponents::PieChartControl, + "piechartsection" => RufletComponents::PieChartSectionControl, "progress_bar" => RufletComponents::ProgressBarControl, "progress_ring" => RufletComponents::ProgressRingControl, "progressbar" => RufletComponents::ProgressBarControl, @@ -339,6 +369,14 @@ module RufletControls "radio" => RufletComponents::RadioControl, "radio_group" => RufletComponents::RadioGroupControl, "radiogroup" => RufletComponents::RadioGroupControl, + "radar_chart" => RufletComponents::RadarChartControl, + "radar_chart_title" => RufletComponents::RadarChartTitleControl, + "radar_data_set" => RufletComponents::RadarDataSetControl, + "radar_data_set_entry" => RufletComponents::RadarDataSetEntryControl, + "radarchart" => RufletComponents::RadarChartControl, + "radarcharttitle" => RufletComponents::RadarChartTitleControl, + "radardataset" => RufletComponents::RadarDataSetControl, + "radardatasetentry" => RufletComponents::RadarDataSetEntryControl, "range_slider" => RufletComponents::RangeSliderControl, "rangeslider" => RufletComponents::RangeSliderControl, "rect" => RufletComponents::RectControl, @@ -358,6 +396,10 @@ module RufletControls "segmentedbutton" => RufletComponents::SegmentedButtonControl, "selection_area" => RufletComponents::SelectionAreaControl, "selectionarea" => RufletComponents::SelectionAreaControl, + "scatter_chart" => RufletComponents::ScatterChartControl, + "scatter_chart_spot" => RufletComponents::ScatterChartSpotControl, + "scatterchart" => RufletComponents::ScatterChartControl, + "scatterchartspot" => RufletComponents::ScatterChartSpotControl, "semantics" => RufletComponents::SemanticsControl, "service_registry" => RufletComponents::ServiceRegistryControl, "serviceregistry" => RufletComponents::ServiceRegistryControl, @@ -392,6 +434,8 @@ module RufletControls "vertical_divider" => RufletComponents::VerticalDividerControl, "verticaldivider" => RufletComponents::VerticalDividerControl, "video" => RufletComponents::VideoControl, + "web_view" => RufletComponents::WebViewControl, + "webview" => RufletComponents::WebViewControl, "view" => RufletComponents::ViewControl, "window" => RufletComponents::WindowControl, "window_drag_area" => RufletComponents::WindowDragAreaControl, diff --git a/packages/ruflet/lib/ruflet_ui/ruflet/ui/material_control_methods.rb b/packages/ruflet/lib/ruflet_ui/ruflet/ui/material_control_methods.rb index 89355bd1..9be08d49 100644 --- a/packages/ruflet/lib/ruflet_ui/ruflet/ui/material_control_methods.rb +++ b/packages/ruflet/lib/ruflet_ui/ruflet/ui/material_control_methods.rb @@ -43,8 +43,9 @@ def text(value = nil, **props) end def button(**props) = build_widget(:button, **props) - def elevated_button(**props) = build_widget(:elevatedbutton, **props) - def elevatedbutton(**props) = elevated_button(**props) + # Ruflet currently uses a single Material button control schema. + # Keep elevated_button DSL available by routing to :button. + def elevated_button(**props) = build_widget(:button, **props) def text_button(**props) = build_widget(:textbutton, **props) def textbutton(**props) = text_button(**props) def filled_button(**props) = build_widget(:filledbutton, **props) @@ -135,6 +136,8 @@ def chart_axis(**props) = build_widget(:chartaxis, **props) def chartaxis(**props) = chart_axis(**props) def chart_axis_label(**props) = build_widget(:chartaxislabel, **props) def chartaxislabel(**props) = chart_axis_label(**props) + def web_view(**props) = build_widget(:webview, **props) + def webview(**props) = web_view(**props) def fab(content = nil, **props) mapped = props.dup diff --git a/packages/ruflet/lib/ruflet_ui/ruflet/ui/services/ruflet/camera_control.rb b/packages/ruflet/lib/ruflet_ui/ruflet/ui/services/ruflet/camera_control.rb index a2cb421d..e26c7bd2 100644 --- a/packages/ruflet/lib/ruflet_ui/ruflet/ui/services/ruflet/camera_control.rb +++ b/packages/ruflet/lib/ruflet_ui/ruflet/ui/services/ruflet/camera_control.rb @@ -8,7 +8,7 @@ class CameraControl < Ruflet::Control TYPE = "camera".freeze WIRE = "Camera".freeze - def initialize(id: nil, align: nil, animate_align: nil, animate_margin: nil, animate_offset: nil, animate_opacity: nil, animate_position: nil, animate_rotation: nil, animate_scale: nil, animate_size: nil, aspect_ratio: nil, badge: nil, bottom: nil, col: nil, content: nil, data: nil, disabled: nil, expand: nil, expand_loose: nil, height: nil, key: nil, left: nil, margin: nil, offset: nil, opacity: nil, preview_enabled: nil, right: nil, rotate: nil, rtl: nil, scale: nil, size_change_interval: nil, tooltip: nil, top: nil, visible: nil, width: nil, on_animation_end: nil, on_size_change: nil, on_state_change: nil, on_stream_image: nil) + def initialize(id: nil, align: nil, animate_align: nil, animate_margin: nil, animate_offset: nil, animate_opacity: nil, animate_position: nil, animate_rotation: nil, animate_scale: nil, animate_size: nil, aspect_ratio: nil, badge: nil, bottom: nil, col: nil, content: nil, data: nil, disabled: nil, expand: nil, expand_loose: nil, height: nil, key: nil, left: nil, margin: nil, offset: nil, opacity: nil, preview_enabled: nil, right: nil, rotate: nil, rtl: nil, scale: nil, size_change_interval: nil, tooltip: nil, top: nil, visible: nil, width: nil, on_animation_end: nil, on_error: nil, on_size_change: nil, on_state_change: nil, on_stream_image: nil) props = {} props[:align] = align unless align.nil? props[:animate_align] = animate_align unless animate_align.nil? @@ -45,6 +45,7 @@ def initialize(id: nil, align: nil, animate_align: nil, animate_margin: nil, ani props[:visible] = visible unless visible.nil? props[:width] = width unless width.nil? props[:on_animation_end] = on_animation_end unless on_animation_end.nil? + props[:on_error] = on_error unless on_error.nil? props[:on_size_change] = on_size_change unless on_size_change.nil? props[:on_state_change] = on_state_change unless on_state_change.nil? props[:on_stream_image] = on_stream_image unless on_stream_image.nil? diff --git a/packages/ruflet/lib/ruflet_ui/ruflet/ui/services/ruflet/filepicker_control.rb b/packages/ruflet/lib/ruflet_ui/ruflet/ui/services/ruflet/filepicker_control.rb index 2205e884..b6e7fd15 100644 --- a/packages/ruflet/lib/ruflet_ui/ruflet/ui/services/ruflet/filepicker_control.rb +++ b/packages/ruflet/lib/ruflet_ui/ruflet/ui/services/ruflet/filepicker_control.rb @@ -8,10 +8,11 @@ class FilePickerControl < Ruflet::Control TYPE = "filepicker".freeze WIRE = "FilePicker".freeze - def initialize(id: nil, data: nil, key: nil, on_upload: nil) + def initialize(id: nil, data: nil, key: nil, on_result: nil, on_upload: nil) props = {} props[:data] = data unless data.nil? props[:key] = key unless key.nil? + props[:on_result] = on_result unless on_result.nil? props[:on_upload] = on_upload unless on_upload.nil? super(type: TYPE, id: id, **props) end diff --git a/packages/ruflet/lib/ruflet_ui/ruflet/ui/shared_control_forwarders.rb b/packages/ruflet/lib/ruflet_ui/ruflet/ui/shared_control_forwarders.rb index c57676b1..f3230686 100644 --- a/packages/ruflet/lib/ruflet_ui/ruflet/ui/shared_control_forwarders.rb +++ b/packages/ruflet/lib/ruflet_ui/ruflet/ui/shared_control_forwarders.rb @@ -22,7 +22,6 @@ def dragtarget(**props, &block) = control_delegate.dragtarget(**props, &block) def text(value = nil, **props) = control_delegate.text(value, **props) def button(**props) = control_delegate.button(**props) def elevated_button(**props) = control_delegate.elevated_button(**props) - def elevatedbutton(**props) = control_delegate.elevatedbutton(**props) def text_button(**props) = control_delegate.text_button(**props) def textbutton(**props) = control_delegate.textbutton(**props) def filled_button(**props) = control_delegate.filled_button(**props) @@ -98,6 +97,8 @@ def chart_axis(**props) = control_delegate.chart_axis(**props) def chartaxis(**props) = control_delegate.chartaxis(**props) def chart_axis_label(**props) = control_delegate.chart_axis_label(**props) def chartaxislabel(**props) = control_delegate.chartaxislabel(**props) + def web_view(**props) = control_delegate.web_view(**props) + def webview(**props) = control_delegate.webview(**props) def cupertino_button(**props) = control_delegate.cupertino_button(**props) def cupertinobutton(**props) = control_delegate.cupertinobutton(**props) def cupertino_filled_button(**props) = control_delegate.cupertino_filled_button(**props) diff --git a/packages/ruflet/test/page_clipboard_test.rb b/packages/ruflet/test/page_clipboard_test.rb index 8c7155b1..55d6a515 100644 --- a/packages/ruflet/test/page_clipboard_test.rb +++ b/packages/ruflet/test/page_clipboard_test.rb @@ -35,7 +35,7 @@ def test_set_clipboard_image_uses_data_key assert_equal({ "data" => "abc123" }, invoke_payload["args"]) end - def test_launch_url_uses_page_invoke_signature + def test_launch_url_uses_url_launcher_service_signature sent = [] page = Ruflet::Page.new( session_id: "s1", @@ -46,9 +46,9 @@ def test_launch_url_uses_page_invoke_signature call_id = page.launch_url("https://flet.dev") refute_nil call_id - invoke_payload = sent.reverse.map(&:last).find { |payload| payload["name"] == "launchUrl" } + invoke_payload = sent.reverse.map(&:last).find { |payload| payload["name"] == "launch_url" } refute_nil invoke_payload - assert_equal 1, invoke_payload["control_id"] + refute_equal 1, invoke_payload["control_id"] assert_equal "https://flet.dev", invoke_payload.dig("args", "url") end end diff --git a/packages/ruflet_cli/lib/ruflet/cli.rb b/packages/ruflet_cli/lib/ruflet/cli.rb index b5e2beb3..2dd3c13d 100644 --- a/packages/ruflet_cli/lib/ruflet/cli.rb +++ b/packages/ruflet_cli/lib/ruflet/cli.rb @@ -54,7 +54,7 @@ def print_help Commands: ruflet create ruflet new - ruflet run [scriptname|path] [--web|--desktop] + ruflet run [scriptname|path] [--web|--desktop] [--port PORT] ruflet debug [scriptname|path] ruflet build ruflet devices diff --git a/packages/ruflet_cli/lib/ruflet/cli/build_command.rb b/packages/ruflet_cli/lib/ruflet/cli/build_command.rb index 028a982b..cabc1aee 100644 --- a/packages/ruflet_cli/lib/ruflet/cli/build_command.rb +++ b/packages/ruflet_cli/lib/ruflet/cli/build_command.rb @@ -1,12 +1,31 @@ # frozen_string_literal: true require "fileutils" +require "uri" require "yaml" module Ruflet module CLI module BuildCommand include FlutterSdk + CLIENT_EXTENSION_MAP = { + "ads" => { package: "flet_ads", alias: "ruflet_ads" }, + "audio" => { package: "flet_audio", alias: "ruflet_audio" }, + "audio_recorder" => { package: "flet_audio_recorder", alias: "ruflet_audio_recorder" }, + "camera" => { package: "flet_camera", alias: "ruflet_camera" }, + "charts" => { package: "flet_charts", alias: "ruflet_charts" }, + "code_editor" => { package: "flet_code_editor", alias: "ruflet_code_editor" }, + "color_pickers" => { package: "flet_color_pickers", alias: "ruflet_color_picker" }, + "datatable2" => { package: "flet_datatable2", alias: "ruflet_datatable2" }, + "flashlight" => { package: "flet_flashlight", alias: "ruflet_flashlight" }, + "geolocator" => { package: "flet_geolocator", alias: "ruflet_geolocator" }, + "lottie" => { package: "flet_lottie", alias: "ruflet_lottie" }, + "map" => { package: "flet_map", alias: "ruflet_map" }, + "permission_handler" => { package: "flet_permission_handler", alias: "ruflet_permission_handler" }, + "secure_storage" => { package: "flet_secure_storage", alias: "ruflet_secure_storage" }, + "video" => { package: "flet_video", alias: "ruflet_video" }, + "webview" => { package: "flet_webview", alias: "ruflet_webview" } + }.freeze def command_build(args) platform = (args.shift || "").downcase @@ -28,11 +47,18 @@ def command_build(args) return 1 end + config = load_ruflet_config tools = ensure_flutter!("build", client_dir: client_dir) - ok = prepare_flutter_client(client_dir, tools: tools) + ok = prepare_flutter_client(client_dir, tools: tools, config: config) return 1 unless ok - ok = system(tools[:env], tools[:flutter], *flutter_cmd, *args, chdir: client_dir) + build_args = [*flutter_cmd, *args] + client_url = configured_client_url(config) + if client_url + build_args += ["--dart-define", "RUFLET_CLIENT_URL=#{client_url}"] + end + + ok = system(tools[:env], tools[:flutter], *build_args, chdir: client_dir) ok ? 0 : 1 end @@ -51,35 +77,72 @@ def detect_flutter_client_dir nil end - def prepare_flutter_client(client_dir, tools:) - apply_build_config(client_dir) + def prepare_flutter_client(client_dir, tools:, config:) + apply_service_extension_config(client_dir, config) + asset_flags = apply_build_config(client_dir, config) + if asset_flags[:error] + warn asset_flags[:error] + return false + end unless system(tools[:env], tools[:flutter], "pub", "get", chdir: client_dir) warn "flutter pub get failed" return false end - unless system(tools[:env], tools[:dart], "run", "flutter_native_splash:create", chdir: client_dir) - warn "flutter_native_splash failed" - return false + if asset_flags[:has_splash] + unless system(tools[:env], tools[:dart], "run", "flutter_native_splash:create", chdir: client_dir) + warn "flutter_native_splash failed" + return false + end end - unless system(tools[:env], tools[:dart], "run", "flutter_launcher_icons", chdir: client_dir) - warn "flutter_launcher_icons failed" - return false + if asset_flags[:has_icon] + unless system(tools[:env], tools[:dart], "run", "flutter_launcher_icons", chdir: client_dir) + warn "flutter_launcher_icons failed" + return false + end end true end - def apply_build_config(client_dir) + def configured_client_url(config) + candidates = [ + config["ruflet_client_url"], + (config["app"].is_a?(Hash) ? config["app"]["ruflet_client_url"] : nil) + ] + raw = candidates.find { |v| !v.to_s.strip.empty? } + return nil if raw.nil? + + value = raw.to_s.strip + uri = URI.parse(value) + return nil unless %w[http https ws wss].include?(uri.scheme) + return nil if uri.host.to_s.strip.empty? + + value + rescue URI::InvalidURIError + nil + end + + def load_ruflet_config config_path = ENV["RUFLET_CONFIG"] || "ruflet.yaml" unless File.file?(config_path) alt = "ruflet.yml" config_path = alt if File.file?(alt) end + return {} unless File.file?(config_path) + YAML.safe_load(File.read(config_path), aliases: true) || {} + rescue StandardError => e + warn "Failed to load ruflet config: #{e.class}: #{e.message}" + {} + end + + def apply_build_config(client_dir, config = {}) + build = config["build"] || {} + assets = config["assets"] || {} + config_path = ENV["RUFLET_CONFIG"] || (File.file?("ruflet.yaml") ? "ruflet.yaml" : "ruflet.yml") config_present = File.file?(config_path) - config = config_present ? (YAML.load_file(config_path) || {}) : {} build = config["build"] || {} assets = config["assets"] || {} config_dir = config_present ? File.dirname(File.expand_path(config_path)) : Dir.pwd @@ -87,44 +150,24 @@ def apply_build_config(client_dir) assets_root = build["assets_dir"] || assets["dir"] || config["assets_dir"] || "assets" assets_root = File.expand_path(assets_root, config_dir) - unless config_present || Dir.exist?(assets_root) || ENV["RUFLET_SPLASH"] || ENV["RUFLET_ICON"] - return - end - resolve_asset = lambda do |path| return nil if path.nil? || path.to_s.strip.empty? full = File.expand_path(path.to_s, config_dir) File.file?(full) ? full : nil end - find_first = lambda do |dir, names| - names.each do |name| - candidate = File.join(dir, name) - return candidate if File.file?(candidate) - end - nil - end + splash_defined = key_defined?(build, "splash_screen") || key_defined?(assets, "splash_screen") || key_defined?(config, "splash_screen") + icon_defined = key_defined?(build, "icon_launcher") || key_defined?(assets, "icon_launcher") || key_defined?(config, "icon_launcher") - splash = resolve_asset.call(build["splash"] || assets["splash"] || ENV["RUFLET_SPLASH"]) + splash = resolve_asset.call(build["splash_screen"] || assets["splash_screen"] || config["splash_screen"]) splash_dark = resolve_asset.call(build["splash_dark"] || build["splash_dark_image"] || assets["splash_dark"]) - icon = resolve_asset.call(build["icon"] || assets["icon"] || ENV["RUFLET_ICON"]) + icon = resolve_asset.call(build["icon_launcher"] || assets["icon_launcher"] || config["icon_launcher"]) icon_android = resolve_asset.call(build["icon_android"] || assets["icon_android"]) icon_ios = resolve_asset.call(build["icon_ios"] || assets["icon_ios"]) icon_web = resolve_asset.call(build["icon_web"] || assets["icon_web"]) icon_windows = resolve_asset.call(build["icon_windows"] || assets["icon_windows"]) icon_macos = resolve_asset.call(build["icon_macos"] || assets["icon_macos"]) - if Dir.exist?(assets_root) - splash ||= find_first.call(assets_root, ["splash.png", "splash.jpg", "splash.webp", "splash.bmp"]) - splash_dark ||= find_first.call(assets_root, ["splash_dark.png", "splash_dark.jpg", "splash_dark.webp", "splash_dark.bmp"]) - icon ||= find_first.call(assets_root, ["icon.png", "icon.jpg", "icon.webp", "icon.bmp"]) - icon_android ||= find_first.call(assets_root, ["icon_android.png", "icon_android.jpg", "icon_android.webp"]) - icon_ios ||= find_first.call(assets_root, ["icon_ios.png", "icon_ios.jpg", "icon_ios.webp"]) - icon_web ||= find_first.call(assets_root, ["icon_web.png", "icon_web.jpg", "icon_web.webp"]) - icon_windows ||= find_first.call(assets_root, ["icon_windows.ico", "icon_windows.png"]) - icon_macos ||= find_first.call(assets_root, ["icon_macos.png", "icon_macos.jpg", "icon_macos.webp"]) - end - splash_color = build["splash_color"] splash_dark_color = build["splash_dark_color"] || build["splash_color_dark"] icon_background = build["icon_background"] @@ -150,10 +193,19 @@ def apply_build_config(client_dir) end copy_asset.call(icon_macos, "icon_macos.png") + if splash_defined && splash.nil? + return { has_icon: false, has_splash: false, error: "build config error: splash_screen is set but file was not found" } + end + if icon_defined && icon.nil? + return { has_icon: false, has_splash: false, error: "build config error: icon_launcher is set but file was not found" } + end + pubspec_path = File.join(client_dir, "pubspec.yaml") - return unless File.file?(pubspec_path) + unless File.file?(pubspec_path) + return { has_icon: icon_defined && !icon.nil?, has_splash: splash_defined && !splash.nil?, error: nil } + end - if icon + if icon_defined && icon update_pubspec_value(pubspec_path, "flutter_launcher_icons", "image_path", "\"assets/icon.png\"", multiple: true) end update_pubspec_value(pubspec_path, "flutter_launcher_icons", "image_path_android", "\"assets/icon_android.png\"", multiple: true) if icon_android @@ -168,10 +220,89 @@ def apply_build_config(client_dir) update_pubspec_value(pubspec_path, "flutter_launcher_icons", "background_color", "\"#{icon_background}\"") if icon_background update_pubspec_value(pubspec_path, "flutter_launcher_icons", "theme_color", "\"#{theme_color}\"") if theme_color - update_pubspec_value(pubspec_path, "flutter_native_splash", "image", "\"assets/splash.png\"") if splash + update_pubspec_value(pubspec_path, "flutter_native_splash", "image", "\"assets/splash.png\"") if splash_defined && splash update_pubspec_value(pubspec_path, "flutter_native_splash", "image_dark", "\"assets/splash_dark.png\"") if splash_dark update_pubspec_value(pubspec_path, "flutter_native_splash", "color", "\"#{splash_color}\"") if splash_color update_pubspec_value(pubspec_path, "flutter_native_splash", "color_dark", "\"#{splash_dark_color}\"") if splash_dark_color + + { has_icon: icon_defined && !icon.nil?, has_splash: splash_defined && !splash.nil?, error: nil } + end + + def key_defined?(hash, key) + hash.is_a?(Hash) && (hash.key?(key) || hash.key?(key.to_sym)) + end + + def apply_service_extension_config(client_dir, config = {}) + services = Array(config["services"]) + extension_keys = services.map { |v| normalize_extension_key(v) }.compact.uniq + extension_packages = extension_keys.filter_map { |key| CLIENT_EXTENSION_MAP[key]&.fetch(:package) }.uniq + extension_aliases = extension_keys.filter_map { |key| CLIENT_EXTENSION_MAP[key]&.fetch(:alias) }.uniq + + pubspec_path = File.join(client_dir, "pubspec.yaml") + main_path = File.join(client_dir, "lib", "main.dart") + prune_client_pubspec(pubspec_path, extension_packages) if File.file?(pubspec_path) + prune_client_main(main_path, extension_aliases) if File.file?(main_path) + end + + def normalize_extension_key(value) + key = value.to_s.strip.downcase + return nil if key.empty? + + key.tr!("-", "_") + key.gsub!(/\A(flet_)+/, "") + key.gsub!(/\Aservice_/, "") + key + end + + def prune_client_pubspec(path, selected_packages) + data = YAML.safe_load(File.read(path), aliases: true) || {} + deps = (data["dependencies"] || {}).dup + + deps.keys.each do |name| + next unless name.start_with?("flet_") + next if name == "flet" + next if selected_packages.include?(name) + + deps.delete(name) + end + + data["dependencies"] = deps + File.write(path, YAML.dump(data)) + end + + def prune_client_main(path, selected_aliases) + lines = File.readlines(path) + alias_to_package = {} + + lines.each do |line| + match = line.match(%r{\Aimport 'package:(flet_[^/]+)/\1\.dart' as ([a-zA-Z0-9_]+);}) + next unless match + + alias_to_package[match[2]] = match[1] + end + + kept = lines.select do |line| + import_match = line.match(%r{\Aimport 'package:(flet_[^/]+)/\1\.dart' as ([a-zA-Z0-9_]+);}) + if import_match + package_name = import_match[1] + next true if package_name == "flet" + next true if selected_aliases.include?(import_match[2]) + next false + end + + extension_match = line.match(/\A\s*([a-zA-Z0-9_]+)\.Extension\(\),\s*\z/) + if extension_match + extension_alias = extension_match[1] + package_name = alias_to_package[extension_alias] + next true if package_name.nil? + next true if selected_aliases.include?(extension_alias) + next false + end + + true + end + + File.write(path, kept.join) end def update_pubspec_value(path, block, key, value, multiple: false) diff --git a/packages/ruflet_cli/lib/ruflet/cli/new_command.rb b/packages/ruflet_cli/lib/ruflet/cli/new_command.rb index 5c4fb8fa..245cdb18 100644 --- a/packages/ruflet_cli/lib/ruflet/cli/new_command.rb +++ b/packages/ruflet_cli/lib/ruflet/cli/new_command.rb @@ -1,10 +1,30 @@ # frozen_string_literal: true require "fileutils" +require "yaml" module Ruflet module CLI module NewCommand + CLIENT_EXTENSION_MAP = { + "ads" => { package: "flet_ads", alias: "ruflet_ads" }, + "audio" => { package: "flet_audio", alias: "ruflet_audio" }, + "audio_recorder" => { package: "flet_audio_recorder", alias: "ruflet_audio_recorder" }, + "camera" => { package: "flet_camera", alias: "ruflet_camera" }, + "charts" => { package: "flet_charts", alias: "ruflet_charts" }, + "code_editor" => { package: "flet_code_editor", alias: "ruflet_code_editor" }, + "color_pickers" => { package: "flet_color_pickers", alias: "ruflet_color_picker" }, + "datatable2" => { package: "flet_datatable2", alias: "ruflet_datatable2" }, + "flashlight" => { package: "flet_flashlight", alias: "ruflet_flashlight" }, + "geolocator" => { package: "flet_geolocator", alias: "ruflet_geolocator" }, + "lottie" => { package: "flet_lottie", alias: "ruflet_lottie" }, + "map" => { package: "flet_map", alias: "ruflet_map" }, + "permission_handler" => { package: "flet_permission_handler", alias: "ruflet_permission_handler" }, + "secure_storage" => { package: "flet_secure_storage", alias: "ruflet_secure_storage" }, + "video" => { package: "flet_video", alias: "ruflet_video" }, + "webview" => { package: "flet_webview", alias: "ruflet_webview" } + }.freeze + def command_new(args) app_name = args.shift if app_name.nil? || app_name.strip.empty? @@ -22,7 +42,9 @@ def command_new(args) File.write(File.join(root, "main.rb"), format(Ruflet::CLI::MAIN_TEMPLATE, app_title: humanize_name(File.basename(root)))) File.write(File.join(root, "Gemfile"), Ruflet::CLI::GEMFILE_TEMPLATE) File.write(File.join(root, "README.md"), format(Ruflet::CLI::README_TEMPLATE, app_name: File.basename(root))) + write_default_ruflet_config(root, File.basename(root)) copy_ruflet_client_template(root) + configure_ruflet_client(root) project_name = File.basename(root) puts "Ruflet app created: #{project_name}" @@ -69,6 +91,129 @@ def prune_client_template(target) end end + def write_default_ruflet_config(root, app_name) + File.write(File.join(root, "ruflet.yaml"), <<~YAML) + app: + name: #{app_name} + # Optional production client endpoint used by `ruflet build`. + # Example: https://api.example.com + ruflet_client_url: "" + + # Source of truth for Flutter client extensions/plugins. + # Examples: camera, video, audio, flashlight, webview, map + services: [] + + # Build assets configuration consumed by `ruflet build`. + # Paths are relative to this file unless absolute. + assets: + dir: assets + splash_screen: assets/splash.png + icon_launcher: assets/icon.png + + build: + splash_color: "#FFFFFF" + splash_dark_color: "#0B0B0B" + icon_background: "#FFFFFF" + theme_color: "#FFFFFF" + YAML + end + + def configure_ruflet_client(root) + config_path = File.join(root, "ruflet.yaml") + return unless File.file?(config_path) + + config = YAML.safe_load(File.read(config_path), aliases: true) || {} + extension_keys = extract_extension_keys(config) + extension_packages = extension_keys.filter_map { |key| CLIENT_EXTENSION_MAP[key]&.fetch(:package) }.uniq + extension_aliases = extension_keys.filter_map { |key| CLIENT_EXTENSION_MAP[key]&.fetch(:alias) }.uniq + + client_dir = File.join(root, "ruflet_client") + apply_client_manifest!(client_dir, extension_packages, extension_aliases) + rescue StandardError => e + warn "Failed to configure ruflet_client from ruflet.yaml: #{e.class}: #{e.message}" + end + + def extract_extension_keys(config) + from_services = Array(config["services"]) + + from_services + .map { |v| normalize_extension_key(v) } + .compact + .uniq + end + + def normalize_extension_key(value) + key = value.to_s.strip.downcase + return nil if key.empty? + + key.tr!("-", "_") + key.gsub!(/\A(flet_)+/, "") + key.gsub!(/\Aservice_/, "") + key.gsub!(/\Acontrol_/, "") + key = "file_picker" if key == "filepicker" + key + end + + def apply_client_manifest!(client_dir, extension_packages, extension_aliases) + return unless Dir.exist?(client_dir) + + pubspec_path = File.join(client_dir, "pubspec.yaml") + main_path = File.join(client_dir, "lib", "main.dart") + prune_client_pubspec(pubspec_path, extension_packages) if File.file?(pubspec_path) + prune_client_main(main_path, extension_aliases) if File.file?(main_path) + end + + def prune_client_pubspec(path, selected_packages) + data = YAML.safe_load(File.read(path), aliases: true) || {} + deps = (data["dependencies"] || {}).dup + + deps.keys.each do |name| + next unless name.start_with?("flet_") + next if name == "flet" + next if selected_packages.include?(name) + + deps.delete(name) + end + + data["dependencies"] = deps + File.write(path, YAML.dump(data)) + end + + def prune_client_main(path, selected_aliases) + lines = File.readlines(path) + alias_to_package = {} + + lines.each do |line| + match = line.match(%r{\Aimport 'package:(flet_[^/]+)/\1\.dart' as ([a-zA-Z0-9_]+);}) + next unless match + + alias_to_package[match[2]] = match[1] + end + + kept = lines.select do |line| + import_match = line.match(%r{\Aimport 'package:(flet_[^/]+)/\1\.dart' as ([a-zA-Z0-9_]+);}) + if import_match + package_name = import_match[1] + next true if package_name == "flet" + next true if selected_aliases.include?(import_match[2]) + next false + end + + extension_match = line.match(/\A\s*([a-zA-Z0-9_]+)\.Extension\(\),\s*\z/) + if extension_match + extension_alias = extension_match[1] + package_name = alias_to_package[extension_alias] + next true if package_name.nil? # non-Flet extension lines + next true if selected_aliases.include?(extension_alias) + next false + end + + true + end + + File.write(path, kept.join) + end + def humanize_name(name) name.to_s.gsub(/[_-]+/, " ").split.map(&:capitalize).join(" ") end diff --git a/packages/ruflet_cli/lib/ruflet/cli/run_command.rb b/packages/ruflet_cli/lib/ruflet/cli/run_command.rb index 0bc0b53b..a5edcbc2 100644 --- a/packages/ruflet_cli/lib/ruflet/cli/run_command.rb +++ b/packages/ruflet_cli/lib/ruflet/cli/run_command.rb @@ -16,10 +16,11 @@ module Ruflet module CLI module RunCommand def command_run(args) - options = { target: "mobile" } + options = { target: "mobile", requested_port: 8550 } parser = OptionParser.new do |o| o.on("--web") { options[:target] = "web" } o.on("--desktop") { options[:target] = "desktop" } + o.on("--port PORT", Integer) { |v| options[:requested_port] = v } end parser.parse!(args) @@ -31,7 +32,7 @@ def command_run(args) return 1 end - selected_port = resolve_backend_port(options[:target]) + selected_port = resolve_backend_port(options[:target], requested_port: options[:requested_port]) return 1 unless selected_port env = { "RUFLET_TARGET" => options[:target], @@ -41,7 +42,7 @@ def command_run(args) assets_dir = File.join(File.dirname(script_path), "assets") env["RUFLET_ASSETS_DIR"] = assets_dir if File.directory?(assets_dir) - print_run_banner(target: options[:target], port: selected_port) + print_run_banner(target: options[:target], requested_port: options[:requested_port], port: selected_port) print_mobile_qr_hint(port: selected_port) if options[:target] == "mobile" gemfile_path = find_nearest_gemfile(Dir.pwd) @@ -126,12 +127,14 @@ def find_nearest_gemfile(start_dir) end end - def print_run_banner(target:, port:) - if target == "mobile" && port != 8550 - puts "Requested port 8550 is busy; bound to #{port}" + def print_run_banner(target:, requested_port:, port:) + if port != requested_port.to_i + puts "Requested port #{requested_port} is busy; bound to #{port}" end if target == "desktop" puts "Ruflet desktop URL: http://localhost:#{port}" + elsif target == "mobile" + puts "Ruflet target: #{target}" else puts "Ruflet target: #{target}" puts "Ruflet URL: http://localhost:#{port}" @@ -162,9 +165,11 @@ def launch_web_client(port) web_pid = Process.spawn("python3", "-m", "http.server", web_port.to_s, "--bind", "127.0.0.1", chdir: web_dir, out: File::NULL, err: File::NULL) Process.detach(web_pid) wait_for_server_boot(web_port) - browser_pid = open_in_browser_app_mode("http://localhost:#{web_port}") - open_in_browser("http://localhost:#{web_port}") if browser_pid.nil? - puts "Ruflet web client: http://localhost:#{web_port}" + backend_url = "http://localhost:#{port}" + web_url = "http://localhost:#{web_port}/?#{URI.encode_www_form(url: backend_url)}" + browser_pid = open_in_browser_app_mode(web_url) + open_in_browser(web_url) if browser_pid.nil? + puts "Ruflet web client: #{web_url}" puts "Ruflet backend ws: ws://localhost:#{port}/ws" [web_pid, browser_pid].compact rescue Errno::ENOENT @@ -532,8 +537,6 @@ def print_mobile_qr_hint(port: 8550) puts puts "Ruflet mobile connect URL:" puts " #{payload}" - puts "Ruflet server ws URL:" - puts " ws://0.0.0.0:#{port}/ws" puts "Scan this QR from ruflet_client (Connect -> Scan QR):" print_ascii_qr(payload) puts @@ -561,8 +564,10 @@ def find_available_port(start_port, max_attempts: 100) start_port end - def resolve_backend_port(target) - find_available_port(8550) + def resolve_backend_port(_target, requested_port: 8550) + base = requested_port.to_i + base = 8550 if base <= 0 + find_available_port(base) end def port_available?(port) diff --git a/packages/ruflet_cli/ruflet_cli-0.0.7.gem b/packages/ruflet_cli/ruflet_cli-0.0.7.gem index 2e2bd2ed..08af774f 100644 Binary files a/packages/ruflet_cli/ruflet_cli-0.0.7.gem and b/packages/ruflet_cli/ruflet_cli-0.0.7.gem differ diff --git a/packages/ruflet_cli/test/new_command_test.rb b/packages/ruflet_cli/test/new_command_test.rb index fe0fb9fe..1f88b184 100644 --- a/packages/ruflet_cli/test/new_command_test.rb +++ b/packages/ruflet_cli/test/new_command_test.rb @@ -23,6 +23,7 @@ def test_command_new_creates_project_scaffold assert File.exist?(File.join(dir, "demo_app", "main.rb")) assert File.exist?(File.join(dir, "demo_app", "Gemfile")) assert File.exist?(File.join(dir, "demo_app", "README.md")) + assert File.exist?(File.join(dir, "demo_app", "ruflet.yaml")) refute File.exist?(File.join(dir, "demo_app", ".bundle", "config")) ensure $stdout = original_stdout @@ -36,4 +37,53 @@ def test_command_new_creates_project_scaffold end end end + + def test_prune_client_manifest_keeps_only_selected_extensions + Dir.mktmpdir do |dir| + client_dir = File.join(dir, "ruflet_client") + FileUtils.mkdir_p(File.join(client_dir, "lib")) + + File.write( + File.join(client_dir, "pubspec.yaml"), + <<~YAML + dependencies: + flutter: + sdk: flutter + flet: + git: + url: https://github.com/flet-dev/flet.git + flet_camera: + git: + url: https://github.com/flet-dev/flet.git + flet_video: + git: + url: https://github.com/flet-dev/flet.git + YAML + ) + + File.write( + File.join(client_dir, "lib", "main.dart"), + <<~DART + import 'package:flet/flet.dart'; + import 'package:flet_camera/flet_camera.dart' as ruflet_camera; + import 'package:flet_video/flet_video.dart' as ruflet_video; + + final extensions = [ + ruflet_camera.Extension(), + ruflet_video.Extension(), + ]; + DART + ) + + Ruflet::CLI.send(:apply_client_manifest!, client_dir, ["flet_camera"], ["ruflet_camera"]) + + pruned_pubspec = File.read(File.join(client_dir, "pubspec.yaml")) + pruned_main = File.read(File.join(client_dir, "lib", "main.dart")) + + assert_includes pruned_pubspec, "flet_camera:" + refute_includes pruned_pubspec, "flet_video:" + assert_includes pruned_main, "ruflet_camera.Extension()" + refute_includes pruned_main, "ruflet_video.Extension()" + end + end end diff --git a/packages/ruflet_server/lib/ruflet/server.rb b/packages/ruflet_server/lib/ruflet/server.rb index 46fa0962..cb489069 100644 --- a/packages/ruflet_server/lib/ruflet/server.rb +++ b/packages/ruflet_server/lib/ruflet/server.rb @@ -196,6 +196,8 @@ def handle_socket(socket) handle_http_request(socket, path) end rescue StandardError => e + return if disconnect_error?(e) + warn "server error: #{e.class}: #{e.message}" warn e.backtrace.join("\n") if e.backtrace send_message(ws, Protocol::ACTIONS[:session_crashed], { "message" => e.message.to_s.dup.force_encoding("UTF-8") }) if ws @@ -211,6 +213,8 @@ def run_connection(ws) handle_message(ws, raw) end rescue StandardError => e + return if disconnect_error?(e) + warn "server error: #{e.class}: #{e.message}" warn e.backtrace.join("\n") if e.backtrace send_message(ws, Protocol::ACTIONS[:session_crashed], { "message" => e.message.to_s.dup.force_encoding("UTF-8") }) @@ -518,12 +522,27 @@ def send_message(ws, action, payload) message = [action, payload] ws.send_binary(Ruflet::WireCodec.pack(message)) rescue StandardError => e - warn "send error: #{e.class}: #{e.message}" + unless disconnect_error?(e) + warn "send error: #{e.class}: #{e.message}" + end remove_session(ws) unregister_connection(ws) ws&.close end + def disconnect_error?(error) + return true if error.is_a?(IOError) + return true if error.is_a?(Errno::EPIPE) + return true if error.is_a?(Errno::ECONNRESET) + return true if error.is_a?(Errno::ECONNABORTED) + return true if error.is_a?(Errno::ENOTCONN) + return true if error.is_a?(Errno::ESHUTDOWN) + return true if error.is_a?(Errno::EBADF) + return true if error.is_a?(Errno::EINVAL) + + false + end + def pseudo_uuid now = Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond) rnd = rand(0..0xffff_ffff) diff --git a/packages/ruflet_server/lib/ruflet/server/web_socket_connection.rb b/packages/ruflet_server/lib/ruflet/server/web_socket_connection.rb index e64fdcf6..453ca62c 100644 --- a/packages/ruflet_server/lib/ruflet/server/web_socket_connection.rb +++ b/packages/ruflet_server/lib/ruflet/server/web_socket_connection.rb @@ -123,6 +123,8 @@ def read_exact(length) end chunk + rescue IOError, SystemCallError + nil end end end diff --git a/ruflet_client/lib/main.dart b/ruflet_client/lib/main.dart index d4083629..9a12f88c 100644 --- a/ruflet_client/lib/main.dart +++ b/ruflet_client/lib/main.dart @@ -169,6 +169,12 @@ void main([List? args]) async { if (routeUrlStrategy == 'path') { usePathUrlStrategy(); } + final queryUrl = Uri.base.queryParameters['url']; + if (queryUrl != null && queryUrl.trim().isNotEmpty) { + initialUrl = queryUrl; + } else if (!kDebugMode) { + initialUrl = 'http://localhost:8550'; + } } else { if (args != null && args.isNotEmpty) { initialUrl = args[0]; @@ -182,15 +188,6 @@ void main([List? args]) async { } } - final isDesktop = - !kIsWeb && - (defaultTargetPlatform == TargetPlatform.windows || - defaultTargetPlatform == TargetPlatform.macOS || - defaultTargetPlatform == TargetPlatform.linux); - if (kIsWeb || isDesktop) { - initialUrl = 'http://localhost:8550'; - } - initialUrl = normalizePageUrlForPlatform(initialUrl); debugPrint('Initial URL: $initialUrl'); diff --git a/templates/ruflet_flutter_template/lib/main.dart b/templates/ruflet_flutter_template/lib/main.dart index e12bdb86..115d5186 100644 --- a/templates/ruflet_flutter_template/lib/main.dart +++ b/templates/ruflet_flutter_template/lib/main.dart @@ -1,32 +1,32 @@ import 'dart:async'; import 'package:flet/flet.dart'; -import 'package:flet_ads/flet_ads.dart' as flet_ads; +import 'package:flet_ads/flet_ads.dart' as ruflet_ads; // --FAT_CLIENT_START-- -import 'package:flet_audio/flet_audio.dart' as flet_audio; +import 'package:flet_audio/flet_audio.dart' as ruflet_audio; // --FAT_CLIENT_END-- import 'package:flet_audio_recorder/flet_audio_recorder.dart' - as flet_audio_recorder; -import 'package:flet_camera/flet_camera.dart' as flet_camera; -import 'package:flet_charts/flet_charts.dart' as flet_charts; -import 'package:flet_code_editor/flet_code_editor.dart' as flet_code_editor; + as ruflet_audio_recorder; +import 'package:flet_camera/flet_camera.dart' as ruflet_camera; +import 'package:flet_charts/flet_charts.dart' as ruflet_charts; +import 'package:flet_code_editor/flet_code_editor.dart' as ruflet_code_editor; import 'package:flet_color_pickers/flet_color_pickers.dart' - as flet_color_picker; -import 'package:flet_datatable2/flet_datatable2.dart' as flet_datatable2; -import 'package:flet_flashlight/flet_flashlight.dart' as flet_flashlight; -import 'package:flet_geolocator/flet_geolocator.dart' as flet_geolocator; -import 'package:flet_lottie/flet_lottie.dart' as flet_lottie; -import 'package:flet_map/flet_map.dart' as flet_map; + as ruflet_color_picker; +import 'package:flet_datatable2/flet_datatable2.dart' as ruflet_datatable2; +import 'package:flet_flashlight/flet_flashlight.dart' as ruflet_flashlight; +import 'package:flet_geolocator/flet_geolocator.dart' as ruflet_geolocator; +import 'package:flet_lottie/flet_lottie.dart' as ruflet_lottie; +import 'package:flet_map/flet_map.dart' as ruflet_map; import 'package:flet_permission_handler/flet_permission_handler.dart' - as flet_permission_handler; + as ruflet_permission_handler; // --FAT_CLIENT_START-- // --FAT_CLIENT_END-- import 'package:flet_secure_storage/flet_secure_storage.dart' - as flet_secure_storage; + as ruflet_secure_storage; // --FAT_CLIENT_START-- -import 'package:flet_video/flet_video.dart' as flet_video; +import 'package:flet_video/flet_video.dart' as ruflet_video; // --FAT_CLIENT_END-- -import 'package:flet_webview/flet_webview.dart' as flet_webview; +import 'package:flet_webview/flet_webview.dart' as ruflet_webview; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_web_plugins/url_strategy.dart'; @@ -35,6 +35,8 @@ import 'connection_probe.dart'; const bool isProduction = bool.fromEnvironment('dart.vm.product'); const int kRufletPort = 8550; +const String kConfiguredClientUrl = + String.fromEnvironment('RUFLET_CLIENT_URL', defaultValue: ''); Tester? tester; String normalizePageUrlForPlatform(String rawUrl) { @@ -74,6 +76,12 @@ String normalizePageUrlForPlatform(String rawUrl) { String fallbackBackendUrl() => normalizePageUrlForPlatform('http://0.0.0.0:$kRufletPort'); +String resolveBackendUrl() { + final configured = parseBackendUrl(kConfiguredClientUrl); + if (configured != null) return configured; + return fallbackBackendUrl(); +} + Future main() async { if (isProduction) { // ignore: avoid_returning_null_for_void @@ -91,24 +99,24 @@ Future main() async { } final extensions = [ - flet_ads.Extension(), - flet_audio_recorder.Extension(), - flet_camera.Extension(), - flet_charts.Extension(), - flet_code_editor.Extension(), - flet_color_picker.Extension(), - flet_datatable2.Extension(), - flet_flashlight.Extension(), - flet_geolocator.Extension(), - flet_lottie.Extension(), - flet_map.Extension(), - flet_permission_handler.Extension(), - flet_secure_storage.Extension(), - flet_webview.Extension(), + ruflet_ads.Extension(), + ruflet_audio_recorder.Extension(), + ruflet_camera.Extension(), + ruflet_charts.Extension(), + ruflet_code_editor.Extension(), + ruflet_color_picker.Extension(), + ruflet_datatable2.Extension(), + ruflet_flashlight.Extension(), + ruflet_geolocator.Extension(), + ruflet_lottie.Extension(), + ruflet_map.Extension(), + ruflet_permission_handler.Extension(), + ruflet_secure_storage.Extension(), + ruflet_webview.Extension(), // --FAT_CLIENT_START-- - flet_audio.Extension(), - flet_video.Extension(), + ruflet_audio.Extension(), + ruflet_video.Extension(), // --FAT_CLIENT_END-- ]; @@ -116,7 +124,7 @@ Future main() async { extension.ensureInitialized(); } - final pageUrl = fallbackBackendUrl(); + final pageUrl = resolveBackendUrl(); await waitForBackend(pageUrl);