Add duty cycle/utilization fields, wheel picker for goals, and updated screenshots

- Add dutyCyclePercent and defaultUtilizationFactorPercent to ComponentLibraryItem
  with normalization logic and backend field fetching
- Change default dailyUsageHours from 1h to 24h
- Replace goal editor stepper with day/hour/minute wheel pickers
- Update app icon colors and remove duplicate icon assets
- Move SavedBattery.swift into Batteries/ directory, remove Pods group
- Add iPad-only flag and start frame support to screenshot framing scripts
- Rework localized App Store screenshot titles across all languages
- Add runtime goals and BOM completed items to sample data
- Bump version to 1.5.1 (build 41)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Stefan Lange-Hegermann
2026-02-17 21:49:21 +01:00
parent 8da6987f32
commit 34e8c0f74b
22 changed files with 571 additions and 371 deletions

View File

@@ -4,12 +4,14 @@ FONT_COLOR="#3C3C3C" # color for light text
FONT_BOLD_COLOR="#B51700" # color for bold text
ONLY_IPHONE=false
ONLY_IPAD=false
usage() {
cat <<'EOF'
Usage: frame_screens.sh [--iphone-only] [SRC_ROOT] [BG_IMAGE] [OUT_ROOT]
Usage: frame_screens.sh [--iphone-only|--ipad-only] [SRC_ROOT] [BG_IMAGE] [OUT_ROOT]
--iphone-only Only frame screenshots whose device slug is not iPad.
--ipad-only Only frame screenshots whose device slug contains iPad.
SRC_ROOT Root folder with device/lang subfolders (default: Shots/Screenshots)
BG_IMAGE Background image to use (default: Shots/frame-bg.png)
OUT_ROOT Output folder for framed shots (default: Shots/Framed)
@@ -23,6 +25,10 @@ while [[ $# -gt 0 ]]; do
ONLY_IPHONE=true
shift
;;
--ipad-only)
ONLY_IPAD=true
shift
;;
-h|--help)
usage
exit 0
@@ -44,6 +50,11 @@ while [[ $# -gt 0 ]]; do
esac
done
if [[ "$ONLY_IPHONE" == true && "$ONLY_IPAD" == true ]]; then
echo "Cannot use --iphone-only and --ipad-only together." >&2
exit 1
fi
if ((${#POSITIONAL_ARGS[@]})); then
set -- "${POSITIONAL_ARGS[@]}"
else
@@ -52,10 +63,11 @@ fi
# Inputs
SRC_ROOT="${1:-Shots/Screenshots}" # root folder with device/lang subfolders (iphone-17-pro-max/en/, ipad-pro-13-inch-m4/de/…)
BG_IMAGE="${2:-Shots/frame-bg.png}" # default background image
BG_IMAGE="${2:-Shots/frame-bg-phone.png}" # default background image for iPhone shots
OUT_ROOT="${3:-Shots/Framed}" # output folder
FONT="./Shots/Fonts/Oswald-Light.ttf" # font for title text
FONT_BOLD="./Shots/Fonts/Oswald-SemiBold.ttf" # font for *emphasized* text
TITLE_POINTSIZE="${TITLE_POINTSIZE:-148}" # baseline point size for titles
# Tweakables
CORNER_RADIUS="auto" # corner radius; "auto" picks a good value based on width
@@ -67,15 +79,24 @@ SHADOW_OFFSET_Y=40 # px
CANVAS_MARGIN=245 # default margin around the device on the background, px
TITLE_MARGIN=378 # default margin above the device for title text, px
TITLE_LINE_SPACING=220 # vertical distance between lines of the title text, px
START_FRAME_IMAGE="${START_FRAME_IMAGE:-Shots/frame-start-iphone.png}" # static framed asset for the intro screen
START_FRAME_FILENAME="${START_FRAME_FILENAME:-00-StartFrameTitle.png}" # output filename for the intro screen
START_FRAME_TITLE_KEY="${START_FRAME_TITLE_KEY:-StartFrameTitle}" # key in the localization files
START_FRAME_TITLE_Y="${START_FRAME_TITLE_Y:-$((TITLE_MARGIN - 100))}" # Y offset for the intro title text
START_FRAME_POINTSIZE="${START_FRAME_POINTSIZE:-$TITLE_POINTSIZE}" # point size for intro title (defaults to standard)
START_FRAME_RESIZE="${START_FRAME_RESIZE:-true}" # resize intro frame to target dimensions
# Device-specific overrides (can be tuned via env vars)
TARGET_WIDTH_PHONE="${TARGET_WIDTH_PHONE:-1320}"
TARGET_HEIGHT_PHONE="${TARGET_HEIGHT_PHONE:-2868}"
TARGET_WIDTH_IPAD="${TARGET_WIDTH_IPAD:-2048}"
TARGET_HEIGHT_IPAD="${TARGET_HEIGHT_IPAD:-2732}"
IPAD_BG_IMAGE="${IPAD_BG_IMAGE:-$BG_IMAGE}"
IPAD_BG_IMAGE="${IPAD_BG_IMAGE:-Shots/frame-bg.png}"
IPAD_CANVAS_MARGIN="${IPAD_CANVAS_MARGIN:-240}"
IPAD_TITLE_MARGIN="${IPAD_TITLE_MARGIN:-180}"
IPAD_FRAME_WIDTH="${IPAD_FRAME_WIDTH:-2200}"
IPAD_FRAME_TOP_OFFSET="${IPAD_FRAME_TOP_OFFSET:-572}"
IPAD_CORNER_RADIUS="${IPAD_CORNER_RADIUS:-60}"
mkdir -p "$OUT_ROOT"
@@ -85,6 +106,8 @@ render_mixed_font_title() {
local title_text="$2"
local title_y="$3"
local output="$4"
local point_size="${5:-$TITLE_POINTSIZE}"
local line_spacing="${6:-$TITLE_LINE_SPACING}"
local expanded_title
expanded_title="$(printf '%b' "$title_text")"
@@ -110,7 +133,7 @@ render_mixed_font_title() {
for idx in "${!lines[@]}"; do
local line="${lines[$idx]}"
local current_y=$((title_y + idx * TITLE_LINE_SPACING))
local current_y=$((title_y + idx * line_spacing))
local -a text_segments=()
local -a font_types=()
@@ -159,7 +182,7 @@ render_mixed_font_title() {
segment_for_measurement="${segment_for_measurement/#/ }"
segment_for_measurement="${segment_for_measurement/%/ }"
local part_width
part_width=$(magick -font "$font_for_measurement" -pointsize 148 -size x label:"$segment_for_measurement" -format "%w" info:)
part_width=$(magick -font "$font_for_measurement" -pointsize "$point_size" -size x label:"$segment_for_measurement" -format "%w" info:)
total_width=$((total_width + part_width))
fi
done
@@ -183,11 +206,11 @@ render_mixed_font_title() {
segment_for_rendering="${segment_for_rendering/#/ }"
segment_for_rendering="${segment_for_rendering/%/ }"
magick "$temp_img" \
-font "$font_to_use" -pointsize 148 -fill "$color_to_use" \
-font "$font_to_use" -pointsize "$point_size" -fill "$color_to_use" \
-gravity northwest -annotate "+$((start_x + x_offset))+${current_y}" "$segment_for_rendering" \
"$temp_img"
local text_width
text_width=$(magick -font "$font_to_use" -pointsize 148 -size x label:"$segment_for_rendering" -format "%w" info:)
text_width=$(magick -font "$font_to_use" -pointsize "$point_size" -size x label:"$segment_for_rendering" -format "%w" info:)
x_offset=$((x_offset + text_width))
fi
done
@@ -224,6 +247,93 @@ get_title() {
echo "***NOT SET***"
}
should_render_start_frame() {
local device_slug="$1"
if [[ ! -f "$START_FRAME_IMAGE" ]]; then
return 1
fi
if [[ -z "$device_slug" ]]; then
return 1
fi
local slug_lower
slug_lower="$(printf '%s' "$device_slug" | tr '[:upper:]' '[:lower:]')"
if [[ "$slug_lower" == *"iphone"* ]]; then
return 0
fi
return 1
}
render_start_frame_for_lang() {
local output_dir="$1"
local lang="$2"
local log_prefix="$3"
local target_width="$4"
local target_height="$5"
local canvas_margin="$6"
local title_margin="$7"
if [[ ! -f "$START_FRAME_IMAGE" ]]; then
return 0
fi
local title_text
title_text="$(get_title "$lang" "$START_FRAME_TITLE_KEY")"
if [[ -z "$title_text" || "$title_text" == "***NOT SET***" ]]; then
echo "Skipped start frame (no title): ${log_prefix}/${START_FRAME_FILENAME}"
return 0
fi
mkdir -p "$output_dir"
local dest="$output_dir/$START_FRAME_FILENAME"
rm -f "$dest"
local resize_flag
resize_flag="$(printf '%s' "$START_FRAME_RESIZE" | tr '[:upper:]' '[:lower:]')"
local working_canvas
working_canvas="$(mktemp /tmp/start_frame_canvas.XXXXXX_$$.png)"
if [[ "$resize_flag" == "true" && -n "${target_width:-}" && -n "${target_height:-}" ]]; then
magick "$START_FRAME_IMAGE" -resize "${target_width}x${target_height}^" -gravity center -extent "${target_width}x${target_height}" "$working_canvas"
else
cp "$START_FRAME_IMAGE" "$working_canvas"
fi
local point_size="$START_FRAME_POINTSIZE"
local scaled_title_y="$START_FRAME_TITLE_Y"
local scaled_line_spacing="$TITLE_LINE_SPACING"
if [[ -n "${target_height:-}" && -n "${canvas_margin:-}" && -n "${title_margin:-}" ]]; then
local base_canvas_height=$(( target_height + 2 * canvas_margin + title_margin ))
if (( base_canvas_height > 0 )); then
read -r scaled_title_y scaled_line_spacing <<EOF
$(python3 - <<PY
target_height=${target_height}
base_canvas_height=${base_canvas_height}
title_y=${START_FRAME_TITLE_Y}
line_spacing=${TITLE_LINE_SPACING}
scale = target_height / base_canvas_height if base_canvas_height else 1
print(int(round(title_y * scale)), int(round(line_spacing * scale)))
PY
)
EOF
fi
fi
if [[ -z "$scaled_title_y" || "$scaled_title_y" -lt 0 ]]; then
scaled_title_y="$START_FRAME_TITLE_Y"
fi
if [[ -z "$scaled_line_spacing" || "$scaled_line_spacing" -le 0 ]]; then
scaled_line_spacing="$TITLE_LINE_SPACING"
fi
render_mixed_font_title "$working_canvas" "$title_text" "$scaled_title_y" "$dest" "$point_size" "$scaled_line_spacing"
rm -f "$working_canvas"
if [[ -f "$dest" ]]; then
echo "Start frame: $log_prefix/$START_FRAME_FILENAME"
fi
}
# Function to frame one screenshot
frame_one () {
local in="$1" # input screenshot (e.g., 1320x2868)
@@ -235,13 +345,25 @@ frame_one () {
local target_height="$7"
local canvas_margin="$8"
local title_margin="$9"
local frame_width="${10:-}"
local frame_top_offset="${11:-}"
local corner_radius_override="${12:-}"
local title_text
title_text="$(get_title "$lang" "$screenshot_name")"
if [[ -z "$title_text" || "$title_text" == "***NOT SET***" ]]; then
echo "Skipped (no title): ${lang}/${screenshot_name}"
return 0
fi
# Read sizes
read -r W H <<<"$(identify -format "%w %h" "$in")"
# Determine corner radius
local R
if [[ "$CORNER_RADIUS" == "auto" ]]; then
if [[ -n "$corner_radius_override" ]]; then
R="$corner_radius_override"
elif [[ "$CORNER_RADIUS" == "auto" ]]; then
# Heuristic: ~1/12 of width works well for iPhone 6.9" (≈110px for 1320px width)
R=$(( W / 12 ))
else
@@ -260,6 +382,9 @@ frame_one () {
local rounded
rounded="$(mktemp /tmp/rounded.XXXXXX_$$.png)"
magick "$in" -alpha set "$mask" -compose copyopacity -composite "$rounded"
if [[ -n "$frame_width" ]]; then
magick "$rounded" -resize "${frame_width}x" "$rounded"
fi
# 2) Shadow from rounded image
local shadow
@@ -278,7 +403,6 @@ frame_one () {
magick "$bg" -resize "${minW}x${minH}^" -gravity center -extent "${minW}x${minH}" "$canvas"
# Add title text above the screenshot
local title_text=$(get_title "$lang" "$screenshot_name")
local with_title
with_title="$(mktemp /tmp/with_title.XXXXXX_$$.png)"
@@ -291,9 +415,15 @@ frame_one () {
# Now place shadow (which already includes the rounded image) positioned below the title
# Calculate the vertical offset to center the screenshot in the remaining space below the title
local screenshot_offset=$((title_margin*2))
local placement_gravity="center"
local placement_geometry="+0+${screenshot_offset}"
if [[ -n "$frame_top_offset" ]]; then
placement_gravity="north"
placement_geometry="+0+${frame_top_offset}"
fi
local temp_result
temp_result="$(mktemp /tmp/temp_result.XXXXXX_$$.png)"
magick "$with_title" "$shadow" -gravity center -geometry "+0+${screenshot_offset}" -compose over -composite "$temp_result"
magick "$with_title" "$shadow" -gravity "$placement_gravity" -geometry "$placement_geometry" -compose over -composite "$temp_result"
# Final step: scale to exact dimensions 1320 × 2868px
magick "$temp_result" -resize "${target_width}x${target_height}^" -gravity center -extent "${target_width}x${target_height}" "$out"
@@ -310,6 +440,9 @@ resolve_device_profile() {
PROFILE_TARGET_HEIGHT="$TARGET_HEIGHT_PHONE"
PROFILE_CANVAS_MARGIN="$CANVAS_MARGIN"
PROFILE_TITLE_MARGIN="$TITLE_MARGIN"
PROFILE_FRAME_WIDTH=""
PROFILE_FRAME_TOP_OFFSET=""
PROFILE_CORNER_RADIUS=""
if [[ -n "$device_slug" ]]; then
local slug_lower
@@ -320,6 +453,9 @@ resolve_device_profile() {
PROFILE_TARGET_HEIGHT="$TARGET_HEIGHT_IPAD"
PROFILE_CANVAS_MARGIN="$IPAD_CANVAS_MARGIN"
PROFILE_TITLE_MARGIN="$IPAD_TITLE_MARGIN"
PROFILE_FRAME_WIDTH="$IPAD_FRAME_WIDTH"
PROFILE_FRAME_TOP_OFFSET="$IPAD_FRAME_TOP_OFFSET"
PROFILE_CORNER_RADIUS="$IPAD_CORNER_RADIUS"
fi
fi
}
@@ -345,29 +481,58 @@ process_lang_dir() {
shopt -s nullglob
for shot in "$lang_path"/*.png; do
local base="$(basename "$shot")"
local dest="$out_dir/$base"
rm -f "$dest"
frame_one \
"$shot" \
"$out_dir/$base" \
"$dest" \
"$PROFILE_BG" \
"$lang" \
"$base" \
"$PROFILE_TARGET_WIDTH" \
"$PROFILE_TARGET_HEIGHT" \
"$PROFILE_CANVAS_MARGIN" \
"$PROFILE_TITLE_MARGIN"
echo "Framed: $log_prefix/$base"
"$PROFILE_TITLE_MARGIN" \
"$PROFILE_FRAME_WIDTH" \
"$PROFILE_FRAME_TOP_OFFSET" \
"$PROFILE_CORNER_RADIUS"
if [[ -f "$dest" ]]; then
echo "Framed: $log_prefix/$base"
fi
done
if should_render_start_frame "$device_slug"; then
render_start_frame_for_lang \
"$out_dir" \
"$lang" \
"$log_prefix" \
"$PROFILE_TARGET_WIDTH" \
"$PROFILE_TARGET_HEIGHT" \
"$PROFILE_CANVAS_MARGIN" \
"$PROFILE_TITLE_MARGIN"
fi
}
shopt -s nullglob
found_any=false
skipped_for_device=false
skipped_for_iphone_filter=false
skipped_for_ipad_filter=false
for entry in "$SRC_ROOT"/*; do
[[ -d "$entry" ]] || continue
entry_basename="$(basename "$entry")"
entry_lower="$(printf '%s' "$entry_basename" | tr '[:upper:]' '[:lower:]')"
if [[ "$ONLY_IPHONE" == true && "$entry_lower" == *"ipad"* ]]; then
skipped_for_device=true
is_ipad_entry=false
if [[ "$entry_lower" == *"ipad"* ]]; then
is_ipad_entry=true
fi
if [[ "$ONLY_IPHONE" == true && "$is_ipad_entry" == true ]]; then
skipped_for_iphone_filter=true
continue
fi
if [[ "$ONLY_IPAD" == true && "$is_ipad_entry" == false ]]; then
skipped_for_ipad_filter=true
continue
fi
@@ -384,7 +549,15 @@ for entry in "$SRC_ROOT"/*; do
lang_device_slug="$(basename "$entry")"
lang_slug_lower="$(printf '%s' "$lang_device_slug" | tr '[:upper:]' '[:lower:]')"
if [[ "$lang_slug_lower" == *"ipad"* ]]; then
skipped_for_device=true
skipped_for_iphone_filter=true
continue
fi
fi
if [[ "$ONLY_IPAD" == true ]]; then
lang_device_slug="$(basename "$entry")"
lang_slug_lower="$(printf '%s' "$lang_device_slug" | tr '[:upper:]' '[:lower:]')"
if [[ "$lang_slug_lower" != *"ipad"* ]]; then
skipped_for_ipad_filter=true
continue
fi
fi
@@ -397,8 +570,18 @@ for entry in "$SRC_ROOT"/*; do
done
if [[ "$found_any" == false ]]; then
if [[ "$ONLY_IPHONE" == true && "$skipped_for_device" == true ]]; then
echo "No iPhone screenshots found under $SRC_ROOT" >&2
if [[ "$ONLY_IPHONE" == true ]]; then
if [[ "$skipped_for_iphone_filter" == true ]]; then
echo "No iPhone screenshots found under $SRC_ROOT" >&2
else
echo "No screenshots found under $SRC_ROOT" >&2
fi
elif [[ "$ONLY_IPAD" == true ]]; then
if [[ "$skipped_for_ipad_filter" == true ]]; then
echo "No iPad screenshots found under $SRC_ROOT" >&2
else
echo "No screenshots found under $SRC_ROOT" >&2
fi
else
echo "No screenshots found under $SRC_ROOT" >&2
fi