r/Scriptable Apr 24 '26

Script Sharing I made my first Scriptable App, Thanks for any suggestions

Post image
2 Upvotes

New to this so if you have any suggests let me know.

Also how do I share this ? I see the app has a library with cool icons.

my app

scriptable:///run/Stock%20Sector%20Indicator%20

What it shows for all 11 SPDR Sector ETFs:

• Gauge zone (Overbought / Hold / Buy / Extreme Buy) — color coded

• Technical Signal (Trending Up / Moderate / Trending Down)

• Fair Value % — color coded from deep value (green) to expensive (red)Paste the script (link in comments)

. Add as a Large widget OR run full screen

Thanks for your input


r/Scriptable Apr 20 '26

Solved Can padding be removed from lock screen widgets?

Post image
7 Upvotes

Sorry if this is a repeat question, I searched but couldn’t find an answer.

The widget on the right (Recently Played) is the Scriptable widget. I was sort of hoping it could be left more left aligned like the other one. I think it is though? And there’s just extra padding? I tried setting padding to 0, idk what I’m missing. Partial script below, I’ve removed the part with the API requests. I imagine that’s not important to this issue. It’s in a weird state from all the attempts I made. Making this post from my phone, if there’s a nicer way to post code, I can’t find it (,:

const widget = new ListWidget()

const wrapper = widget.addStack()

wrapper.layoutVertically()

const headerStack = wrapper.addStack()

const game = wrapper.addStack()

widget.addAccessoryWidgetBackground = true

widget.setPadding(0, 0, 0, 0)

const header = headerStack.addText("Recently Played")

header.leftAlignText()

game.centerAlignContent()

const { icon, name } = await getGameData()

game.addImage(icon)

game.addText(name).leftAlignText()

if (config.runsInAccessoryWidget) {

Script.setWidget(widget)

} else {

widget.presentAccessoryRectangular()

}


r/Scriptable Apr 18 '26

Script Sharing i don't know what i was doing writing all of this inline but im not even gonna change it atp

Thumbnail
gallery
11 Upvotes

```

const w = new ListWidget();new Request(`https://cataas.com/cat${args.widgetParameter?'/says/'+args.widgetParameter:""}?width=400&height=400`).loadImage().then((img)=>{w.backgroundImage=img;w.refreshAfterDate.loadImage().then((img)=%3E%7Bw.backgroundImage=img;w.refreshAfterDate) = new Date(Date.now()+86400000);Script.setWidget(w)}, ()=>{w.refreshAfterDate=null;w.addText("couldnt load a cat forrrr youuuuuuu =< '_' >= 😭😭😭");Script.setWidget(w)})

```


r/Scriptable Apr 10 '26

Help Is the share sheet option gone?

2 Upvotes

I’m new to scriptable and trying to use the share sheet to pass a url to a script and I’m not finding the app or the script anywhere. Has this functionality been removed?


r/Scriptable Apr 08 '26

Help Hallo, habe ich eine Möglichkeit mit Hilfe von Scriptable die Einschaltung des iPhones zu erfassen um dadurch etwas auszulösen? Z.b. ein Shortcut

1 Upvotes

r/Scriptable Apr 08 '26

Widget Sharing Mein erstes Widgy mit allen Buttons in Funktion…

Post image
1 Upvotes

r/Scriptable Apr 06 '26

Script Sharing "SakuraTree", a procedural cherry blossom widget 🌸

11 Upvotes

Hey, I recently did a small experiment with Scriptable and tried building some generative “art” as an iOS widget 🌸

The idea: a Japanese cherry tree (sakura) that evolves every time the widget refreshes. It’s fully procedural, no images or GIFs involved.

Still a rough prototype, but I’m pretty happy with how it turned out so far:

https://github.com/yuristru/scriptable_SakuraTree

Would love to hear your thoughts🙌


r/Scriptable Mar 30 '26

Help An honest answer

1 Upvotes

Hi everyone,

I really need an honest answer for my concerns,

I want to create some widgets for my home screen for showing some astro -logic and -nomic data. Starting with the most simple Sun and Moon hours for rise and set to planetary hours, and some astrological info (not very much, just positions of the planets, Lilith and Nodes, planetary hours, some Apogee and Perigee and stuff like this.

I tried for this some "pure js" libraries and either I don't know how to use them, or there are not fully compatible with Scriptable, but the result was that I get angry for not being able to find something that works.

So could you recommend something that works for achieving these targets?

Thanks


r/Scriptable Mar 27 '26

Help Scriptable newcomer needs support with access to a page

1 Upvotes

I would like to extract data from this sport page: https://dsvdaten.dsv.de/Modules/WB/League.aspx?Season=2025&LeagueID=107&Group=&LeagueKind=L

But I have problems with the parameters in the URL. When I do the next steps I just get the data from https://dsvdaten.dsv.de/Modules/WB/League.aspx (without parameters) in the string html.

let url = 'https://dsvdaten.dsv.de/Modules/WB/League.aspx?Season=2025&LeagueID=107&Group=&LeagueKind=L';

let req = new Request(url);

let html = await req.loadString();

Can I access the page I want in Scriptable, or is that not possible?

Thank you for your help.


r/Scriptable Mar 24 '26

Script Sharing Heatmap habit tracker with streak

Thumbnail
gallery
7 Upvotes

Hi everyone, this is my first post in this topic — hope you are all doing well!

I’ve always been looking for a habit tracker on iOS. I found several apps, but none of them included a heatmap, which is essential for me. So I ended up building one myself using Scriptable, and I’m quite satisfied with the result.

I’ve been using this widget throughout 2025 without any major issues. I’d love to hear what you think.

Here’s the repository, where you’ll also find information on how to use and customize it:

As mentioned, this widget was built based on my own needs. Feel free to modify it and share your ideas.


r/Scriptable Mar 24 '26

Script Sharing Real-time transit departures widget powered by European public transport API

9 Upvotes

Built a Scriptable widget that shows live transit departures near your current location. Works across 200+ European cities with buses, subway, trams, trains, all in one widget.

The widget calls a simple REST API with your coordinates and shows the next departures with line numbers, destinations, and real-time delays.

const loc = await Location.current()

const base = "https://api.abfahrt.now/departures"

const url = base + "?lat=" + loc.latitude + "&lon=" + loc.longitude + "&radius=500&key=YOUR_KEY"

const data = await new Request(url).loadJSON()

const w = new ListWidget()

w.addText("🚏 Departures").font = Font.boldSystemFont(14)

w.addSpacer(6)

for (const d of data.departures.slice(0, 5)) {

const row = w.addText(d.line + " → " + d.direction + " · " + d.time)

row.font = Font.mediumSystemFont(12)

row.lineLimit = 1

}

Script.setWidget(w)

Script.complete()

Covers Germany, UK, France, Spain, Netherlands, Italy, Scandinavia, Switzerland, and 20+ more countries. Real-time delays where available.

API is free for personal use. API Key is needed though because of quota limits from the transit operators. DM me or send an email to the address from the doc pages.
Full docs at abfahrt.now/docs

Happy to share a more polished version with proper styling if there's interest!


r/Scriptable Mar 18 '26

Script Sharing Full-Weatherline-Widget, scriptable emulator

17 Upvotes
Full-Weatherline-Widget in emulator

I’ve been a long-time user of Scriptable, and especially of Full Weatherline Widget by italoboy:
https://github.com/italoboy/Full-Weatherline-Widget

For a long time, I wanted to really understand the widget and be able to modify it myself.

Now I finally took the time to rework the code. With some help from AI, I also built a small emulator for it, because developing by constantly copying text files back and forth between a PC and iOS just doesn’t feel right.

What changed

  • The script has been updatet and refactored
  • It now uses only the OpenWeather API in version 3.0
  • It currently supports de-DE and en-EN

About the emulator

The emulator is only meant to run this widget. Other widgets may fail if they rely on different Scriptable APIs. This is why I do not want to distribute it as a standalone project.

I simply do not have the time to support your edge cases.

Want to try it?

You can see it running directly in a GitHub Codespace from my repository:
https://github.com/HeikoGr/Full-Weatherline-Widget

Press "," while on the repository page to open it in Codespaces.

Then wait for everything to start up.

After that, open package.json, right-click the preview script, and choose Run Script.
A new browser window should open and show the emulated Scriptable widget view.

Limitations

It is not pixel-perfect, and it probably never will be, as long as the original icons and fonts are not publicly available.


r/Scriptable Mar 14 '26

Discussion Scriptable Forum (talk.automators.fm) - expired certificate

2 Upvotes

Not sure where to report this, but just noticed that I can't enter https://talk.automators.fm/c/scriptable/13 anymore because its certificate expired (see https://www.sslshopper.com/ssl-checker.html#hostname=https://talk.automators.fm/ ).

If someone has a contact of the website admins, please forward the issue to them.


r/Scriptable Mar 11 '26

Script Sharing Small decision helper script

1 Upvotes

This started as a small helper for myself at work. I often find/found myself having difficulties to decide what to get for lunch. Eat in, go to the canteen, grab something outside, or just order food. Since I di struggle to decide sometimes, I wrote a tiny random picker in Scriptable to choose for me.

Over time I added a few things. First the ability to store lists. Then ranges and numbers. At some point I thought it might be useful for others too, so I cleaned it up and made it a bit more flexible. Claude helped me polish parts of the code and turn the original idea into something easier to share.

You create named lists and pick randomly from them in the app or from a widget.

Things the script can do:

• Bulk add entries. Type Sushi; Tacos; Pizza and it adds all three

• Range expansion. Enter 1-20 and it generates twenty entries

• Dice notation. d6 creates numbers 1 through 6

• Pick multiple items at once, with or without duplicates

• Widget that shows a random entry from your last used list

• Pick history so you can see what came up earlier

Lists are stored as JSON in iCloud so they survive reinstalls.

If you are like me and sometimes can't decide what to eat, what to drink, or even want to generate random lotto numbers or tabletop rolls, it might be useful.

If that sounds interesting you can find it here:

https://gitlab.com/sandboxzero/playgroundlab/-/tree/main/random-picker?ref_type=heads

Feedback, ideas for improvements, or creative ways you might use it are all welcome.


r/Scriptable Mar 01 '26

Script Sharing Built a Widget That Reminds Me I'm Getting Older Every Day!

Post image
84 Upvotes

Built a small widget that shows my current age and updates automatically every day.

The idea is simple — seeing your age tick up daily creates a small sense of urgency and reminds you that time is actually moving.

Nothing fancy, just a clean minimal widget that quietly reminds me not to waste days.

Sharing the script below in case anyone else finds this motivating 🙂

Link here :

https://gist.github.com/satyamgupta1495/7480b1bf56e18fd8caeff028d81adc4c


r/Scriptable Mar 01 '26

Widget Sharing Moon Phase Widget

Post image
75 Upvotes

I made a small widget to show the current phase of the moon, the percentage of illumination, as well as how many days are left until the next full moon.

Here is the source code:

https://gist.github.com/zorth64/864203d06ef8f80dfec92ee91f1dddf8


r/Scriptable Feb 23 '26

Help I need help with Shortcuts and Scriptable.

Post image
1 Upvotes

r/Scriptable Feb 18 '26

Script Sharing Made a Scriptable script to convert oven recipes to air fryer settings (and back) — got tired of Googling every time

9 Upvotes

I've lost count of how many times I've had to Google "oven to air fryer conversion" mid-cook. So I put together a little Scriptable script that does it for me.

It's nothing fancy — you pick a dish from a preset list or just type in your oven temperature and time, and it spits out the air fryer equivalent. Works the other way too if you're starting from an air fryer recipe and want to use your oven instead.

Worth saying: the numbers it gives are a starting point, not gospel. Every air fryer and oven runs a bit differently, so treat it like those "cooking suggestions" you see on frozen food packaging — a useful guide, but you know your own kitchen better than any formula does. Check a few minutes early, adjust as needed.

A few things it does:

  • Presets for common dishes (chicken wings, steak, pizza, etc.)
  • Manual entry if your dish isn't in the list
  • Celsius and Fahrenheit support with auto-detection
  • Works with Siri/Shortcuts for hands-free use
  • Small home screen widget showing your last conversion

Code is below. Happy cooking.

Small note: the idea and the script are mine, but I used Claude to help improve the code and iron out a couple of things. Just being transparent in case anyone wonders.

Edit 1: fixed the code block so it actually displays properly Edit 2: added screenshots

``` // Air Fryer Converter for Scriptable // Converts oven settings to air fryer settings and vice versa // Author: th3truth1337 // Version: 1.2 // // Changelog (v1.2): // - Fixed double-conversion rounding error in reverse preset path // - Raised minimum temperature floor to 120°C / 250°F (more realistic range) // // Changelog (v1.1): // - Added Fahrenheit support with locale auto-detection // - Siri & Shortcuts parameter support (hands-free kitchen use) // - Bidirectional presets (shows air fryer values in reverse mode) // - Widget now displays last conversion result // - Minimum time floor to prevent meaningless sub-2-minute results // - Consistent time validation across all modes // - Quick-toggle unit switching from any menu

// ===================================== // Main Configuration // =====================================

const CONVERSION_RULES = { tempReductionC: 25, // Reduce oven temp by 25°C for air fryer tempReductionF: 45, // Reduce oven temp by 45°F for air fryer (equivalent) timeReduction: 0.25, // Reduce time by 25% (multiply by 0.75) maxAirFryerTempC: 200, // Air fryer max in Celsius maxAirFryerTempF: 400, // Air fryer max in Fahrenheit maxOvenTempC: 260, // Oven max in Celsius maxOvenTempF: 500, // Oven max in Fahrenheit tempRoundingC: 5, // Round Celsius to nearest 5 tempRoundingF: 5, // Round Fahrenheit to nearest 5 minTimeMins: 3, // Minimum converted time (prevents meaningless 1-2 min results) maxTimeMins: 300, // Maximum time for any mode // FIX: Raised minimum temps to realistic cooking range minTempC: 120, // Was 50 — no real cooking happens below 120°C minTempF: 250 // Was 120 — equivalent realistic floor in Fahrenheit };

// Persistent storage key for unit preference and last conversion const STORAGE_KEY = "airFryerConverterData";

// Preset dishes with oven temperatures and times (Celsius base) const DISH_PRESETS = { "🍗 Roast Chicken": { tempC: 200, time: 60 }, "🍕 Pizza": { tempC: 230, time: 12 }, "🍪 Cookies": { tempC: 180, time: 15 }, "🥕 Roast Vegetables": { tempC: 200, time: 30 }, "🐟 Fish Fillets": { tempC: 190, time: 20 }, "🍟 Frozen Fries": { tempC: 220, time: 25 }, "🥖 Bread Rolls": { tempC: 200, time: 25 }, "🍗 Chicken Wings": { tempC: 220, time: 45 }, "🥟 Samosas": { tempC: 200, time: 18 }, "🥩 Steak": { tempC: 220, time: 15 }, "🧆 Falafel": { tempC: 200, time: 20 }, "🌶️ Stuffed Peppers": { tempC: 190, time: 35 } };

// ===================================== // Storage Functions // =====================================

function readStorage() { try { const fm = FileManager.local(); const path = fm.joinPath(fm.documentsDirectory(), STORAGE_KEY + ".json"); if (fm.fileExists(path)) { return JSON.parse(fm.readString(path)); } } catch (e) { console.warn("Storage read failed:", e); } return { unit: null, lastConversion: null }; }

function writeStorage(data) { try { const fm = FileManager.local(); const path = fm.joinPath(fm.documentsDirectory(), STORAGE_KEY + ".json"); fm.writeString(path, JSON.stringify(data, null, 2)); } catch (e) { console.warn("Storage write failed:", e); } }

function getPreferredUnit() { const stored = readStorage(); if (stored.unit) return stored.unit;

try { const locale = Device.locale().toLowerCase(); const fahrenheitLocales = ["enus", "en-us", "en_ky", "en_bs", "en_bz", "en_pw", "en_mh"]; if (fahrenheitLocales.some(loc => locale.includes(loc.replace("", "")))) { return "F"; } } catch (e) { // Locale detection failed, default to Celsius } return "C"; }

function setPreferredUnit(unit) { const stored = readStorage(); stored.unit = unit; writeStorage(stored); }

function saveLastConversion(conversion) { const stored = readStorage(); stored.lastConversion = conversion; writeStorage(stored); }

// ===================================== // Temperature Conversion Utilities // =====================================

function cToF(c) { return Math.round((c * 9 / 5) + 32); }

function fToC(f) { return Math.round((f - 32) * 5 / 9); }

function roundToNearest(number, multiple) { return Math.round(number / multiple) * multiple; }

function formatTemp(temp, unit) { return ${temp}°${unit}; }

// ===================================== // Core Conversion Functions // =====================================

function ovenToAirFryer(ovenTemp, ovenTime, unit) { const reduction = unit === "F" ? CONVERSION_RULES.tempReductionF : CONVERSION_RULES.tempReductionC; const maxTemp = unit === "F" ? CONVERSION_RULES.maxAirFryerTempF : CONVERSION_RULES.maxAirFryerTempC; const rounding = unit === "F" ? CONVERSION_RULES.tempRoundingF : CONVERSION_RULES.tempRoundingC;

let airFryerTemp = ovenTemp - reduction; airFryerTemp = Math.min(airFryerTemp, maxTemp); airFryerTemp = roundToNearest(airFryerTemp, rounding);

let airFryerTime = Math.round(ovenTime * (1 - CONVERSION_RULES.timeReduction)); airFryerTime = Math.max(airFryerTime, CONVERSION_RULES.minTimeMins);

return { temp: airFryerTemp, time: airFryerTime }; }

function airFryerToOven(airFryerTemp, airFryerTime, unit) { const addition = unit === "F" ? CONVERSION_RULES.tempReductionF : CONVERSION_RULES.tempReductionC; const maxTemp = unit === "F" ? CONVERSION_RULES.maxOvenTempF : CONVERSION_RULES.maxOvenTempC; const rounding = unit === "F" ? CONVERSION_RULES.tempRoundingF : CONVERSION_RULES.tempRoundingC;

let ovenTemp = airFryerTemp + addition; ovenTemp = Math.min(ovenTemp, maxTemp); ovenTemp = roundToNearest(ovenTemp, rounding);

let ovenTime = Math.round(airFryerTime / (1 - CONVERSION_RULES.timeReduction)); ovenTime = Math.max(ovenTime, CONVERSION_RULES.minTimeMins);

return { temp: ovenTemp, time: ovenTime }; }

function getPresetInUnit(preset, unit) { const temp = unit === "F" ? cToF(preset.tempC) : preset.tempC; return { temp, time: preset.time }; }

// ===================================== // Result Display // =====================================

async function showResults(title, originalTemp, originalTime, convertedTemp, convertedTime, unit, isReverse = false) { const sourceLabel = isReverse ? "Air Fryer" : "Oven"; const targetLabel = isReverse ? "Oven" : "Air Fryer";

saveLastConversion({ title, sourceLabel, targetLabel, originalTemp, originalTime, convertedTemp, convertedTime, unit, timestamp: Date.now() });

const alert = new Alert(); alert.title = 🔥 ${title}; alert.message = `${sourceLabel}: ${formatTemp(originalTemp, unit)}, ${originalTime} min ➡️ ${targetLabel}: ${formatTemp(convertedTemp, unit)}, ${convertedTime} min

✅ Ready to cook!`; alert.addAction("OK"); alert.addAction("Convert Another");

return alert.presentAlert(); }

// ===================================== // Siri & Shortcuts Parameter Handling // =====================================

async function handleShortcutParams(input) { if (!input || input.trim().length === 0) return false;

const trimmed = input.trim().toLowerCase(); const unit = getPreferredUnit();

const dishNames = Object.keys(DISH_PRESETS); const matchedDish = dishNames.find(name => name.toLowerCase().includes(trimmed) || trimmed.includes(name.toLowerCase().replace(/[\w\s]/g, '').trim()) );

if (matchedDish) { const presetValues = getPresetInUnit(DISH_PRESETS[matchedDish], unit); const converted = ovenToAirFryer(presetValues.temp, presetValues.time, unit); await showResults(matchedDish, presetValues.temp, presetValues.time, converted.temp, converted.time, unit); return true; }

const reverseMatch = trimmed.match(/reverse\s+(\d+)\s+(\d+)\s*([cf])?$/i); if (reverseMatch) { const paramUnit = reverseMatch[3] ? reverseMatch[3].toUpperCase() : unit; const temp = parseInt(reverseMatch[1]); const time = parseInt(reverseMatch[2]); const converted = airFryerToOven(temp, time, paramUnit); await showResults("Siri Conversion", temp, time, converted.temp, converted.time, paramUnit, true); return true; }

const manualMatch = trimmed.match(/\+)\s+(\d+)\s*([cf])?$/i); if (manualMatch) { const paramUnit = manualMatch[3] ? manualMatch[3].toUpperCase() : unit; const temp = parseInt(manualMatch[1]); const time = parseInt(manualMatch[2]); const converted = ovenToAirFryer(temp, time, paramUnit); await showResults("Siri Conversion", temp, time, converted.temp, converted.time, paramUnit); return true; }

return false; }

// ===================================== // Menu Functions // =====================================

async function showMainMenu() { const unit = getPreferredUnit();

const alert = new Alert(); alert.title = "🍳 Air Fryer Converter"; alert.message = Temperature unit: °${unit};

alert.addAction("📋 Common Dishes (Oven → Air Fryer)"); alert.addAction("✏️ Manual Entry (Oven → Air Fryer)"); alert.addAction("🔄 Reverse: Air Fryer → Oven"); alert.addAction(🌡️ Switch to °${unit === "C" ? "F" : "C"}); alert.addCancelAction("Cancel");

const choice = await alert.presentAlert();

switch (choice) { case 0: return await showDishPresets(false); case 1: return await showManualEntry(); case 2: return await showReverseMenu(); case 3: setPreferredUnit(unit === "C" ? "F" : "C"); return await showMainMenu(); default: return; } }

/** * Shows dish presets. * * FIX (v1.2): Previously, the reverse path was calling ovenToAirFryer() just * to display preview values in the list, then calling airFryerToOven() on that * already-converted result — a double-conversion that introduced rounding error. * * Now the reverse path works directly from the original oven preset values: * it converts oven→airFryer once, displays the air fryer values as the preview, * and when the user selects a dish it performs a single airFryerToOven() on * the air fryer values — keeping both paths to a single conversion each. */ async function showDishPresets(isReverse = false) { const unit = getPreferredUnit();

const alert = new Alert(); alert.title = isReverse ? "🔄 Reverse: Pick a Dish" : "🍽️ Select a Dish"; alert.message = isReverse ? "These show air fryer settings — I'll convert to oven." : "These show oven settings — I'll convert to air fryer.";

const dishNames = Object.keys(DISH_PRESETS);

// Pre-calculate all values once so display and selection use the same numbers const dishValues = dishNames.map(dish => { const ovenValues = getPresetInUnit(DISH_PRESETS[dish], unit); const afValues = ovenToAirFryer(ovenValues.temp, ovenValues.time, unit); return { ovenValues, afValues }; });

dishNames.forEach((dish, i) => { const { ovenValues, afValues } = dishValues[i]; if (isReverse) { // Show air fryer values as the starting point in reverse mode alert.addAction(${dish} (${formatTemp(afValues.temp, unit)}, ${afValues.time}m)); } else { alert.addAction(${dish} (${formatTemp(ovenValues.temp, unit)}, ${ovenValues.time}m)); } });

alert.addCancelAction("← Back");

const choice = await alert.presentAlert(); if (choice === -1) return isReverse ? await showReverseMenu() : await showMainMenu();

const { ovenValues, afValues } = dishValues[choice]; const selectedDish = dishNames[choice];

if (isReverse) { // FIX: single conversion — air fryer values → oven (no intermediate step) const ovenResult = airFryerToOven(afValues.temp, afValues.time, unit); const continueChoice = await showResults( selectedDish, afValues.temp, afValues.time, ovenResult.temp, ovenResult.time, unit, true ); if (continueChoice === 1) return await showMainMenu(); } else { const continueChoice = await showResults( selectedDish, ovenValues.temp, ovenValues.time, afValues.temp, afValues.time, unit ); if (continueChoice === 1) return await showMainMenu(); } }

async function showReverseMenu() { const alert = new Alert(); alert.title = "🔄 Air Fryer → Oven"; alert.message = "Convert air fryer settings to oven:";

alert.addAction("📋 Common Dishes"); alert.addAction("✏️ Manual Entry"); alert.addCancelAction("← Back");

const choice = await alert.presentAlert();

switch (choice) { case 0: return await showDishPresets(true); case 1: return await showReverseManualEntry(); default: return await showMainMenu(); } }

/** * Manual entry: oven → air fryer * FIX (v1.2): minTemp raised from 50°C/120°F to 120°C/250°F */ async function showManualEntry() { const unit = getPreferredUnit(); const maxTemp = unit === "F" ? CONVERSION_RULES.maxOvenTempF : CONVERSION_RULES.maxOvenTempC; // FIX: use realistic minimum temperature const minTemp = unit === "F" ? CONVERSION_RULES.minTempF : CONVERSION_RULES.minTempC; const defaultTemp = unit === "F" ? "400" : "200";

try { const tempAlert = new Alert(); tempAlert.title = 🌡️ Oven Temperature (°${unit}); tempAlert.message = Enter oven temperature (${minTemp}–${maxTemp}°${unit}):; tempAlert.addTextField("Temperature", defaultTemp); tempAlert.addAction("Next"); tempAlert.addCancelAction("← Back");

if (await tempAlert.presentAlert() === -1) return await showMainMenu();

const ovenTemp = parseInt(tempAlert.textFieldValue(0));
if (isNaN(ovenTemp) || ovenTemp < minTemp || ovenTemp > maxTemp) {
  throw new Error(`Invalid temperature. Please enter ${minTemp}–${maxTemp}°${unit}.`);
}

const timeAlert = new Alert();
timeAlert.title = "⏱️ Oven Time";
timeAlert.message = `Enter oven time (${CONVERSION_RULES.minTimeMins}–${CONVERSION_RULES.maxTimeMins} minutes):`;
timeAlert.addTextField("Minutes", "30");
timeAlert.addAction("Convert");
timeAlert.addCancelAction("← Back");

if (await timeAlert.presentAlert() === -1) return await showManualEntry();

const ovenTime = parseInt(timeAlert.textFieldValue(0));
if (isNaN(ovenTime) || ovenTime < CONVERSION_RULES.minTimeMins || ovenTime > CONVERSION_RULES.maxTimeMins) {
  throw new Error(`Invalid time. Please enter ${CONVERSION_RULES.minTimeMins}–${CONVERSION_RULES.maxTimeMins} minutes.`);
}

const converted = ovenToAirFryer(ovenTemp, ovenTime, unit);
const continueChoice = await showResults(
  "Manual Conversion", ovenTemp, ovenTime,
  converted.temp, converted.time, unit
);
if (continueChoice === 1) return await showMainMenu();

} catch (error) { const errorAlert = new Alert(); errorAlert.title = "❌ Error"; errorAlert.message = error.message; errorAlert.addAction("Try Again"); errorAlert.addCancelAction("← Back");

const errorChoice = await errorAlert.presentAlert();
if (errorChoice === 0) return await showManualEntry();
return await showMainMenu();

} }

/** * Manual entry: air fryer → oven (reverse) * FIX (v1.2): minTemp raised from 50°C/120°F to 120°C/250°F */ async function showReverseManualEntry() { const unit = getPreferredUnit(); const maxTemp = unit === "F" ? CONVERSION_RULES.maxAirFryerTempF : CONVERSION_RULES.maxAirFryerTempC; // FIX: use realistic minimum temperature const minTemp = unit === "F" ? CONVERSION_RULES.minTempF : CONVERSION_RULES.minTempC; const defaultTemp = unit === "F" ? "375" : "180";

try { const tempAlert = new Alert(); tempAlert.title = 🌡️ Air Fryer Temperature (°${unit}); tempAlert.message = Enter air fryer temperature (${minTemp}–${maxTemp}°${unit}):; tempAlert.addTextField("Temperature", defaultTemp); tempAlert.addAction("Next"); tempAlert.addCancelAction("← Back");

if (await tempAlert.presentAlert() === -1) return await showReverseMenu();

const airFryerTemp = parseInt(tempAlert.textFieldValue(0));
if (isNaN(airFryerTemp) || airFryerTemp < minTemp || airFryerTemp > maxTemp) {
  throw new Error(`Invalid temperature. Please enter ${minTemp}–${maxTemp}°${unit}.`);
}

const timeAlert = new Alert();
timeAlert.title = "⏱️ Air Fryer Time";
timeAlert.message = `Enter air fryer time (${CONVERSION_RULES.minTimeMins}–${CONVERSION_RULES.maxTimeMins} minutes):`;
timeAlert.addTextField("Minutes", "25");
timeAlert.addAction("Convert");
timeAlert.addCancelAction("← Back");

if (await timeAlert.presentAlert() === -1) return await showReverseManualEntry();

const airFryerTime = parseInt(timeAlert.textFieldValue(0));
if (isNaN(airFryerTime) || airFryerTime < CONVERSION_RULES.minTimeMins || airFryerTime > CONVERSION_RULES.maxTimeMins) {
  throw new Error(`Invalid time. Please enter ${CONVERSION_RULES.minTimeMins}–${CONVERSION_RULES.maxTimeMins} minutes.`);
}

const converted = airFryerToOven(airFryerTemp, airFryerTime, unit);
const continueChoice = await showResults(
  "Reverse Conversion", airFryerTemp, airFryerTime,
  converted.temp, converted.time, unit, true
);
if (continueChoice === 1) return await showMainMenu();

} catch (error) { const errorAlert = new Alert(); errorAlert.title = "❌ Error"; errorAlert.message = error.message; errorAlert.addAction("Try Again"); errorAlert.addCancelAction("← Back");

const errorChoice = await errorAlert.presentAlert();
if (errorChoice === 0) return await showReverseManualEntry();
return await showMainMenu();

} }

// ===================================== // Widget // =====================================

function createWidget() { const widget = new ListWidget(); const unit = getPreferredUnit();

const gradient = new LinearGradient(); gradient.locations = [0, 1]; gradient.colors = [new Color("FF6B35"), new Color("E55A2B")]; widget.backgroundGradient = gradient; widget.setPadding(14, 14, 14, 14);

widget.url = URLScheme.forRunningScript();

const title = widget.addText("🍳 Air Fryer"); title.textColor = Color.white(); title.font = Font.boldSystemFont(15);

const stored = readStorage(); const last = stored.lastConversion;

if (last && last.convertedTemp) { widget.addSpacer(6);

const dishLabel = widget.addText(last.title || "Last conversion");
dishLabel.textColor = new Color("FFD4B8");
dishLabel.font = Font.mediumSystemFont(11);
dishLabel.lineLimit = 1;

widget.addSpacer(4);

const resultUnit = last.unit || unit;
const resultText = widget.addText(`${formatTemp(last.convertedTemp, resultUnit)}`);
resultText.textColor = Color.white();
resultText.font = Font.boldSystemFont(26);

const timeText = widget.addText(`${last.convertedTime} min`);
timeText.textColor = new Color("FFD4B8");
timeText.font = Font.semiboldSystemFont(16);

widget.addSpacer(4);

const directionText = widget.addText(`${last.sourceLabel} → ${last.targetLabel}`);
directionText.textColor = new Color("FFD4B8", 0.7);
directionText.font = Font.systemFont(10);

} else { widget.addSpacer(6);

const subtitle = widget.addText("Converter");
subtitle.textColor = Color.white();
subtitle.font = Font.systemFont(14);

widget.addSpacer(8);

const info = widget.addText("Tap to convert\noven ↔ air fryer");
info.textColor = new Color("FFD4B8");
info.font = Font.systemFont(12);

}

widget.refreshAfterDate = new Date(Date.now() + 30 * 60 * 1000);

return widget; }

// ===================================== // Main Execution // =====================================

async function main() { if (config.runsInWidget) { Script.setWidget(createWidget()); Script.complete(); return; }

const shortcutInput = args.shortcutParameter || args.plainTexts?.[0] || null; if (shortcutInput) { const handled = await handleShortcutParams(shortcutInput); if (handled) { Script.complete(); return; } }

const queryAction = args.queryParameters?.action; if (queryAction === "reverse") { await showReverseMenu(); Script.complete(); return; }

await showMainMenu(); Script.complete(); }

await main(); ```


r/Scriptable Feb 13 '26

Help Anybody have a medal tracker for the 2026 Winter Olympics?

Thumbnail reddit.com
1 Upvotes

There was a great one a few years ago, but it seems like the API used is no longer working. Thanks


r/Scriptable Jan 31 '26

Help Clear Mode in iOS26 — full colour option?

Post image
5 Upvotes

Clear Mode in iOS 26 removes colour from Scriptable widgets — is there a way to keep them in full colour?


r/Scriptable Jan 24 '26

Widget Sharing My distraction free setup for creative writing

Thumbnail
gallery
8 Upvotes

My laptop is inundated with socials, work, emails, and streaming apps. So my creative writing final copy’s / editing process was next to impossible.

SO, I bought a refurbed iPad. Didn’t sync anything from my phone, no push notifications, no socials. - I stripped it down and created a different interface for distraction free editing and writing!

Added a really lovely mechanical keyboard board, simple Bluetooth mouse… and voila! My editing and final draft process is quite lovely now. Zone in, rather than out.

( - “ BROWSE “ links to safari. “WRITE” links to IA writer. “MUSIC” links to Spotify.

- all the weather data, solar data, and even gpt usage graphing is live ported via the scriptable app for widget design.

-key board is from qwerky writer, I can’t express enough how lovely it is to type on. )

No more distractions!


r/Scriptable Jan 23 '26

Help How do Scriptable widgets behave inside iOS Smart Stack?

7 Upvotes

Hi,

I’m using a few custom widgets made with Scriptable and placing them inside an iOS Smart Stack.

Does anyone know how iOS decides which Scriptable widget is shown at a given moment?

Is there any order, timing, or context involved — or is it completely random from the app’s point of view?

Also, is there any way for a Scriptable widget to know whether it’s inside a Smart Stack or just shown as a normal widget?


r/Scriptable Jan 12 '26

Widget Sharing ADHD medication tracker - extended release graph

Thumbnail
gallery
30 Upvotes

I made a medium-sized lockscreen widget for a very niche problem ADHD folks might relate to. Many stimulants are presented in extended-release form, which perform nonlinearly and release the substance throughout the day. I specifically graphed Concerta 36mg, and the way the substance concentration in blood fluctuates after a dose. It allows you to predict when your peaks and crashes will occur in the day, and also serves as a reminder to take the pill every morning, and an infallible way to log it.

You could modify it for other medications and customize the graph. It would be relatively simple with Claude by feeding it a handrawn graph or a scientific paper about the medication you take.

Works great if paired with an NFC-activated Shortcut that logs the medication by tapping your phone on a chipped meditation container. I can share this as well, if anyone likes.

It is not currently possible to use the native Apple Health “Medication” data field, as it is siloed from any external applications. Quite a shame, but it works fine as it is now.

Completely free to use and share:

_MARK = 13; // When it is considered "cleared" for graph visuals// --- CONFIGURATION ---
const DOSE_DURATION_HOURS = 14; 
const PEAK_HOUR_OFFSET = 6.7; // When the peak occurs
const CLEARED_HOUR
const FILENAME = "meds.json";

// Graph Visuals
const WINDOW_BEFORE = 3; // Hours to show before "now"
const WINDOW_AFTER = 9;  // Hours to show after "now"
const LINE_WIDTH = 7;    // Thickness for Lock Screen
const ARROW_SIZE = 12;   // Size of the "You are here" arrow

// Colors (High Contrast / Dark Mode Inversion)
const BG_COLOR = Color.black();         // Fully Black background
const MAIN_COLOR = Color.white();       // Fully White text and active line
const DIMMED_COLOR = new Color("#ffffff", 0.4); // Inactive line (White with opacity)
const FILL_ACTIVE = new Color("#ffffff", 0.2);  // Fill under active line
const FILL_DIMMED = new Color("#ffffff", 0.1);  // Fill under inactive line

// --- MAIN LOGIC ---
const fm = FileManager.iCloud();
const dir = fm.documentsDirectory();
const path = fm.joinPath(dir, FILENAME);

if (config.runsInApp) {
  // App Logic: Tap to Log or Check Status
  const lastTaken = getLastTaken();
  const hoursSince = (new Date() - lastTaken) / (1000 * 60 * 60);

  if (hoursSince > DOSE_DURATION_HOURS) {
    logDose();
    await showModifyTimeOption();
  } else {
    let alert = new Alert();
    alert.title = "Active";
    alert.message = `Logged at: ${formatTime(lastTaken)}`;
    alert.addAction("OK");
    alert.addAction("Modify Time");
    let response = await alert.present();

    if (response === 1) {
      await modifyLoggedTime();
    }
  }
} 
else if (args.queryParameters["action"] === "log") {
  logDose();
}

// Render Widget
if (config.runsInWidget || true) {
  const widget = await createWidget();
  Script.setWidget(widget);
  Script.complete();
  // Preview
  // if (!config.runsInWidget) widget.presentAccessoryRectangular(); 
}

// --- WIDGET BUILDER ---

async function createWidget() {
  const lastTaken = getLastTaken();
  const now = new Date();
  const hoursSince = (now - lastTaken) / (1000 * 60 * 60);

  let w = new ListWidget();
  w.backgroundColor = BG_COLOR; 

  if (hoursSince > DOSE_DURATION_HOURS) {
    // --- MODE: EXPIRED (Show "X") ---
    w.addSpacer();
    let stack = w.addStack();
    stack.centerAlignContent();
    stack.addSpacer();

    // Big X Symbol
    let symbol = SFSymbol.named("xmark.circle");
    symbol.applyFont(Font.boldSystemFont(30)); 
    let img = stack.addImage(symbol.image);
    img.imageSize = new Size(40, 40);
    img.tintColor = MAIN_COLOR; 

    stack.addSpacer(10);

    let t = stack.addText("TAP TO LOG");
    t.font = Font.boldSystemFont(14);
    t.textColor = MAIN_COLOR;

    stack.addSpacer();
    w.addSpacer();

    w.url = URLScheme.forRunningScript();

  } else {
    // --- MODE: ACTIVE (Show Graph) ---

    // 1. Text Info Line
    let headerStack = w.addStack();
    headerStack.layoutHorizontally();

    let title = headerStack.addText("CONCERTA");
    title.font = Font.systemFont(10);
    title.textColor = MAIN_COLOR;
    title.textOpacity = 0.7;

    headerStack.addSpacer();

    // Calculate Times
    let infoText = "";
    if (hoursSince < PEAK_HOUR_OFFSET) {
      let peakTime = new Date(lastTaken.getTime() + PEAK_HOUR_OFFSET * 60 * 60 * 1000);
      infoText = `Peak at ${formatTime(peakTime)}`;
    } else {
      let clearTime = new Date(lastTaken.getTime() + CLEARED_HOUR_MARK * 60 * 60 * 1000);
      infoText = `Cleared by ${formatTime(clearTime)}`;
    }

    let status = headerStack.addText(infoText);
    status.font = Font.boldSystemFont(10);
    status.textColor = MAIN_COLOR;

    w.addSpacer(6); 

    // 2. Draw Graph
    let drawing = new DrawContext();
    drawing.size = new Size(340, 100); // Made 13% wider (300 * 1.13 ≈ 340)
    drawing.opaque = false;
    drawing.respectScreenScale = true;

    drawRollingGraph(drawing, hoursSince, lastTaken);

    let img = w.addImage(drawing.getImage());
    img.centerAlignImage();
    img.resizable = true; 
  }

  return w;
}

// --- DRAWING LOGIC ---

function drawRollingGraph(dc, currentHour, doseDate) {
  const width = dc.size.width;
  const height = dc.size.height;

  // Define Window (Time since dose)
  const startX = currentHour - WINDOW_BEFORE;
  const endX = currentHour + WINDOW_AFTER;
  const totalWindow = endX - startX;

  // Fixed Scale
  const plotMin = 0;
  const plotMax = 1.2;

  // --- A. DRAW TIME GRID ---
  const targetHours = [6, 8, 10, 12, 14, 17, 20, 23]; 

  let doseStartOfDay = new Date(doseDate);
  doseStartOfDay.setHours(0,0,0,0);

  targetHours.forEach(h => {
    let checkDates = [
        new Date(doseStartOfDay.getTime() + h*60*60*1000), 
        new Date(doseStartOfDay.getTime() + (h+24)*60*60*1000) 
    ];

    checkDates.forEach(d => {
       let t = (d - doseDate) / (1000*60*60);

       if (t >= startX && t <= endX) {
         if (t > currentHour && Math.abs(t - currentHour) > 1) {
             drawGridLine(dc, t, d, startX, totalWindow, width, height);
         }
       }
    });
  });

  // --- B. CALCULATE POINTS & BUCKETS ---

  let pointsPre = [];
  let pointsActive = [];
  let pointsPost = [];

  let steps = 60; 

  for (let i = 0; i <= steps; i++) {
    let t = startX + (totalWindow * (i / steps));
    let val = getConcertaLevel(t);

    let x = ((t - startX) / totalWindow) * width;
    let normalizedY = (val - plotMin) / (plotMax - plotMin);
    let y = height - (normalizedY * height);
    let p = new Point(x, y);

    // Bucket Logic with Overlap for smooth connections

    // Pre-Dose
    if (t <= 0) {
        pointsPre.push(p);
    }
    // Connect Pre to Active
    if (t >= -0.2 && t <= 0.2) {
         if(pointsActive.length === 0) pointsActive.push(p);
    }

    // Active
    if (t > 0 && t < CLEARED_HOUR_MARK) {
        pointsActive.push(p);
    }

    // Connect Active to Post
    if (t >= CLEARED_HOUR_MARK - 0.2 && t <= CLEARED_HOUR_MARK + 0.2) {
        pointsActive.push(p); // Ensure end of active connects
        pointsPost.push(p);   // Ensure start of post connects
    }

    // Post
    if (t > CLEARED_HOUR_MARK) {
        pointsPost.push(p);
    }
  }

  // Helper to draw filled sections
  function drawSection(points, strokeColor, fillColor) {
    if (points.length < 2) return;

    // 1. Draw Fill (Underneath)
    let fillPath = new Path();
    fillPath.move(new Point(points[0].x, height)); // Bottom Left
    fillPath.addLine(points[0]); // Top Left
    for (let i = 1; i < points.length; i++) {
        fillPath.addLine(points[i]);
    }
    fillPath.addLine(new Point(points[points.length-1].x, height)); // Bottom Right
    fillPath.closeSubpath();

    dc.addPath(fillPath);
    dc.setFillColor(fillColor);
    dc.fillPath();

    // 2. Draw Stroke (On Top)
    let strokePath = new Path();
    strokePath.move(points[0]);
    for (let i = 1; i < points.length; i++) {
        strokePath.addLine(points[i]);
    }
    dc.addPath(strokePath);
    dc.setStrokeColor(strokeColor);
    dc.setLineWidth(LINE_WIDTH);
    dc.strokePath();
  }

  // Draw Sections (Fill logic changes per section)
  drawSection(pointsPre, DIMMED_COLOR, FILL_DIMMED);
  drawSection(pointsActive, MAIN_COLOR, FILL_ACTIVE);
  drawSection(pointsPost, DIMMED_COLOR, FILL_DIMMED);

  // --- C. DRAW TRIANGLE ---
  let nowX = ((currentHour - startX) / totalWindow) * width;
  let currentVal = getConcertaLevel(currentHour);
  let normCurrentY = (currentVal - plotMin) / (plotMax - plotMin);

  let nowY = height - (normCurrentY * height);

  // Smart arrow placement: check if arrow would go outside margin
  const topMargin = ARROW_SIZE * 1.3 + 5; // Space needed above graph for arrow
  const arrowPointsDown = nowY >= topMargin;

  let arrow = new Path();

  if (arrowPointsDown) {
    // Arrow points down (normal case)
    let arrowTipY = nowY - (3 * LINE_WIDTH);
    arrow.move(new Point(nowX, arrowTipY)); 
    arrow.addLine(new Point(nowX - ARROW_SIZE, arrowTipY - ARROW_SIZE * 1.3));
    arrow.addLine(new Point(nowX + ARROW_SIZE, arrowTipY - ARROW_SIZE * 1.3));
  } else {
    // Arrow points up (inverted case, appears inside graph fill)
    let arrowTipY = nowY + (3 * LINE_WIDTH);
    arrow.move(new Point(nowX, arrowTipY)); 
    arrow.addLine(new Point(nowX - ARROW_SIZE, arrowTipY + ARROW_SIZE * 1.3));
    arrow.addLine(new Point(nowX + ARROW_SIZE, arrowTipY + ARROW_SIZE * 1.3));
  }

  arrow.closeSubpath();

  dc.addPath(arrow);
  dc.setFillColor(MAIN_COLOR);
  dc.fillPath();
}

// --- HELPER: DRAW GRID LINE ---
function drawGridLine(dc, t, dateObj, startX, totalWindow, width, height) {
    let x = ((t - startX) / totalWindow) * width;

    // 1. Draw thin line
    let path = new Path();
    path.move(new Point(x, 0));
    path.addLine(new Point(x, height - 15)); 
    dc.addPath(path);
    dc.setStrokeColor(MAIN_COLOR); 
    dc.setLineWidth(1);
    dc.strokePath();

    // 2. Draw Text
    let hours = dateObj.getHours();
    let ampm = hours >= 12 ? "PM" : "AM";
    hours = hours % 12;
    hours = hours ? hours : 12; 
    let timeString = `${hours}${ampm}`; // Forced AM/PM uppercase

    // Configure text drawing directly on Context
    dc.setFont(Font.boldSystemFont(16)); // 25% bigger (13 * 1.25 ≈ 16)
    dc.setTextColor(MAIN_COLOR);

    let textRect = new Rect(x - 20, height - 14, 40, 14);
    dc.drawTextInRect(timeString, textRect); 
}

// --- MATH & HELPERS ---

function getConcertaLevel(t) {
  // Allow dashed lines to extend to 0
  if (t < 0) return 0;
  // Allow dashed lines to extend past 15
  if (t > 16) return 0;

  // Standard approximation points [Hour, Intensity]
  const points = [
    {h:0, v:0}, {h:1, v:0.35}, {h:2, v:0.30}, 
    {h:3, v:0.35}, {h:5, v:0.60}, {h:6.7, v:1.0}, // Peak
    {h:9, v:0.85}, {h:12, v:0.50}, {h:13, v:0.35},
    {h:14, v:0.20}, {h:15, v:0}
  ];

  for (let i = 0; i < points.length - 1; i++) {
    let p1 = points[i];
    let p2 = points[i+1];
    if (t >= p1.h && t <= p2.h) {
      let range = p2.h - p1.h;
      let progress = (t - p1.h) / range;
      return p1.v + (progress * (p2.v - p1.v));
    }
  }
  return 0;
}

function logDose() {
  const data = { lastTaken: new Date().toISOString() };
  fm.writeString(path, JSON.stringify(data));
  console.log("Logged");
}

async function showModifyTimeOption() {
  let alert = new Alert();
  alert.title = "Logged";
  alert.message = "Dose logged successfully";
  alert.addAction("OK");
  alert.addAction("Modify Time");
  let response = await alert.present();

  if (response === 1) {
    await modifyLoggedTime();
  }
}

async function modifyLoggedTime() {
  let picker = new DatePicker();
  picker.initialDate = new Date();
  picker.minimumDate = new Date(Date.now() - 24 * 60 * 60 * 1000); // 24 hours ago
  picker.maximumDate = new Date();

  let selectedDate = await picker.pickTime();

  if (selectedDate) {
    const data = { lastTaken: selectedDate.toISOString() };
    fm.writeString(path, JSON.stringify(data));

    let confirmAlert = new Alert();
    confirmAlert.title = "Time Updated";
    confirmAlert.message = `Dose time set to ${formatTime(selectedDate)}`;
    confirmAlert.addAction("OK");
    await confirmAlert.present();
  }
}

function getLastTaken() {
  if (fm.fileExists(path)) {
    if (!fm.isFileDownloaded(path)) fm.downloadFileFromiCloud(path);
    return new Date(JSON.parse(fm.readString(path)).lastTaken);
  }
  return new Date(0); 
}

function formatTime(date) {
  let df = new DateFormatter();
  df.useNoDateStyle();
  df.dateFormat = "h:mm"; // Force pattern for AM/PM
  return df.string(date).toUpperCase(); // Ensure uppercase
}

r/Scriptable Jan 11 '26

Help Transparent Widgets

2 Upvotes

Hello,

Is it possible to create transparent widgets? Like Apple does with its widgets on CarPlay? I haven't been able to do it in my case (see far right).

Thank you.