// Variables used by Scriptable. // icon-color: green; icon-glyph: dollar-sign; // Currency. widget for Scriptable. No API key, ads or subscriptions. // Install guide: https://vishar-currency.pages.dev/install.html // Tapping the widget opens the web app below. Nothing here needs editing. const APP_URL = 'https://vishar-currency.pages.dev/'; const DEFAULT_PARAMETER = 'USD:100:GBP,RUB,EUR,AED,KZT,JPY,TRY'; // Widget Parameter syntax: BASE:AMOUNT:OTHER_CURRENCY_CODES // Example: GBP:250:USD,EUR,RUB,KZT const FLAGS = { USD:'๐Ÿ‡บ๐Ÿ‡ธ', RUB:'๐Ÿ‡ท๐Ÿ‡บ', GBP:'๐Ÿ‡ฌ๐Ÿ‡ง', EUR:'๐Ÿ‡ช๐Ÿ‡บ', AED:'๐Ÿ‡ฆ๐Ÿ‡ช', KZT:'๐Ÿ‡ฐ๐Ÿ‡ฟ', JPY:'๐Ÿ‡ฏ๐Ÿ‡ต', TRY:'๐Ÿ‡น๐Ÿ‡ท', KRW:'๐Ÿ‡ฐ๐Ÿ‡ท', EGP:'๐Ÿ‡ช๐Ÿ‡ฌ', CHF:'๐Ÿ‡จ๐Ÿ‡ญ', CAD:'๐Ÿ‡จ๐Ÿ‡ฆ', AUD:'๐Ÿ‡ฆ๐Ÿ‡บ', CNY:'๐Ÿ‡จ๐Ÿ‡ณ', INR:'๐Ÿ‡ฎ๐Ÿ‡ณ', UAH:'๐Ÿ‡บ๐Ÿ‡ฆ', PLN:'๐Ÿ‡ต๐Ÿ‡ฑ', SEK:'๐Ÿ‡ธ๐Ÿ‡ช', NOK:'๐Ÿ‡ณ๐Ÿ‡ด', DKK:'๐Ÿ‡ฉ๐Ÿ‡ฐ', NZD:'๐Ÿ‡ณ๐Ÿ‡ฟ', SGD:'๐Ÿ‡ธ๐Ÿ‡ฌ', HKD:'๐Ÿ‡ญ๐Ÿ‡ฐ', THB:'๐Ÿ‡น๐Ÿ‡ญ', VND:'๐Ÿ‡ป๐Ÿ‡ณ', IDR:'๐Ÿ‡ฎ๐Ÿ‡ฉ', MYR:'๐Ÿ‡ฒ๐Ÿ‡พ', PHP:'๐Ÿ‡ต๐Ÿ‡ญ', SAR:'๐Ÿ‡ธ๐Ÿ‡ฆ', ILS:'๐Ÿ‡ฎ๐Ÿ‡ฑ', CZK:'๐Ÿ‡จ๐Ÿ‡ฟ', HUF:'๐Ÿ‡ญ๐Ÿ‡บ', RON:'๐Ÿ‡ท๐Ÿ‡ด', BGN:'๐Ÿ‡ง๐Ÿ‡ฌ', BRL:'๐Ÿ‡ง๐Ÿ‡ท', MXN:'๐Ÿ‡ฒ๐Ÿ‡ฝ', ZAR:'๐Ÿ‡ฟ๐Ÿ‡ฆ', GEL:'๐Ÿ‡ฌ๐Ÿ‡ช', AMD:'๐Ÿ‡ฆ๐Ÿ‡ฒ', AZN:'๐Ÿ‡ฆ๐Ÿ‡ฟ', UZS:'๐Ÿ‡บ๐Ÿ‡ฟ', BYN:'๐Ÿ‡ง๐Ÿ‡พ', PKR:'๐Ÿ‡ต๐Ÿ‡ฐ', TWD:'๐Ÿ‡น๐Ÿ‡ผ' }; const GREEN = new Color('#A6CD68'); const WHITE = new Color('#F5F6F2'); const MUTED = new Color('#99A293'); const BG = new Color('#151915'); const now = Date.now(); function parseParameter(value) { const pieces = String(value || DEFAULT_PARAMETER).trim().split(':'); let base = (pieces[0] || 'USD').trim().toUpperCase(); if (!/^[A-Z]{3}$/.test(base)) base = 'USD'; let rawAmount = (pieces[1] || '100').replace(/[\s\u00a0']/g, ''); if (rawAmount.includes('.') || /^\d{1,3}(,\d{3})+$/.test(rawAmount)) rawAmount = rawAmount.replace(/,/g, ''); else rawAmount = rawAmount.replace(',', '.'); let amount = Number(rawAmount); if (!isFinite(amount) || amount < 0 || amount > 1e12) amount = 100; let codes = (pieces[2] || 'GBP,RUB,EUR,AED,KZT,JPY,TRY').split(',').map(x => x.trim().toUpperCase()).filter(x => /^[A-Z]{3}$/.test(x) && x !== base); codes = codes.filter((x,i) => codes.indexOf(x) === i).slice(0,8); return {base, amount, codes}; } function formatted(n) { if (!isFinite(n)) return 'โ€”'; return n.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ','); } function valid(data, base) { return Boolean(data && typeof data.date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(data.date) && data[base.toLowerCase()] && data[base.toLowerCase()][base.toLowerCase()] === 1); } async function getRates(base) { const fm = FileManager.local(); const file = fm.joinPath(fm.documentsDirectory(), 'currency-widget-' + base + '-v1.json'); let saved = null; try { if (fm.fileExists(file)) saved = JSON.parse(fm.readString(file)); } catch (e) { console.log('Cannot read previous rate cache: ' + e); } if (saved && valid(saved.data, base) && now - saved.checkedAt < 6 * 60 * 60 * 1000) { return {data:saved.data, cached:true}; } const urls = [ 'https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/' + base.toLowerCase() + '.json', 'https://latest.currency-api.pages.dev/v1/currencies/' + base.toLowerCase() + '.json' ]; for (const url of urls) { try { const request = new Request(url); request.timeoutInterval = 9; const data = await request.loadJSON(); if (!valid(data,base)) throw new Error('Invalid API response'); try { fm.writeString(file,JSON.stringify({data:data,checkedAt:now})); } catch (e) { console.log('Could not save cache: ' + e); } return {data:data,cached:false}; } catch(e) { console.log('Rate endpoint failed: ' + e); } } return saved && valid(saved.data,base) ? {data:saved.data,cached:true,stale:true} : null; } function txt(stack,value,size,color,font) { const t=stack.addText(value); t.font=font==='bold'?Font.boldSystemFont(size):(font==='mono'?Font.semiboldMonospacedSystemFont(size):Font.systemFont(size)); t.textColor=color; t.lineLimit=1; t.minimumScaleFactor=0.6; return t; } function prettyDate(iso) { const m=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; const p=String(iso).split('-'); return p.length===3 ? Number(p[2])+' '+(m[Number(p[1])-1]||p[1]) : String(iso); } // One rate row: flag + code on the left, amount pushed to the right edge. function addLine(parent, code, n, fs) { const row=parent.addStack();row.layoutHorizontally();row.centerAlignContent(); txt(row,(FLAGS[code]||'ยค')+' '+code,fs.code,MUTED,'normal'); row.addSpacer(); const value=txt(row,formatted(n),fs.value,WHITE,'bold'); value.rightAlignText(); return row; } function rateRows(parent, limit, fs, gap) { if (!result) { txt(parent,'Rates unavailable',fs.code+1,WHITE,'bold'); parent.addSpacer(3); txt(parent,'Connect and tap to retry',fs.code-2,MUTED,'normal'); return; } const exchange=result.data[options.base.toLowerCase()]; let rendered=0; for (const code of options.codes) { const rate=exchange[code.toLowerCase()]; if (!(typeof rate==='number' && isFinite(rate) && rate>0)) continue; if (rendered>0) gap===null ? parent.addSpacer() : parent.addSpacer(gap); addLine(parent,code,options.amount*rate,fs);rendered++; if(rendered>=limit)break; } if(rendered===0)txt(parent,'Choose other currencies',fs.code,MUTED,'normal'); } const options = parseParameter(args.widgetParameter); const result = await getRates(options.base); const size = config.widgetFamily || 'medium'; const small = size === 'small'; const large = size === 'large' || size === 'extraLarge'; const widget = new ListWidget(); widget.backgroundColor=BG; widget.spacing=0; widget.refreshAfterDate=new Date(now + 3*60*60*1000); if (/^https:\/\/[^\s]+$/i.test(APP_URL)) widget.url = APP_URL; const info = result ? prettyDate(result.data.date) + (result.stale?' ยท offline':'') : 'No saved rates'; const baseLabel=(FLAGS[options.base]||'ยค')+' '+formatted(options.amount); if (small) { widget.setPadding(13,14,12,14); const head=widget.addStack();head.layoutHorizontally();head.centerAlignContent(); txt(head,'Currency.',12,GREEN,'bold');head.addSpacer();txt(head,options.base,10,MUTED,'normal'); widget.addSpacer(4); txt(widget,baseLabel,20,WHITE,'bold'); widget.addSpacer(); rateRows(widget,2,{code:12,value:15},6); widget.addSpacer(); txt(widget,info,9,MUTED,'normal'); } else if (large) { widget.setPadding(18,18,16,18); const head=widget.addStack();head.layoutHorizontally();head.centerAlignContent(); txt(head,'Currency.',16,GREEN,'bold');head.addSpacer();txt(head,info,11,MUTED,'normal'); widget.addSpacer(10); txt(widget,baseLabel,30,WHITE,'bold'); widget.addSpacer(14); rateRows(widget,8,{code:15,value:19},null); widget.addSpacer(4); } else { // Medium: base amount on the left, four rates in a right-aligned column. widget.setPadding(14,16,14,16); const main=widget.addStack();main.layoutHorizontally(); const left=main.addStack();left.layoutVertically();left.size=new Size(112,0); txt(left,'Currency.',14,GREEN,'bold'); left.addSpacer(); txt(left,FLAGS[options.base]||'ยค',20,WHITE,'normal'); left.addSpacer(2); txt(left,formatted(options.amount),22,WHITE,'bold'); txt(left,options.base,11,MUTED,'normal'); left.addSpacer(); txt(left,info,9,MUTED,'normal'); main.addSpacer(14); const right=main.addStack();right.layoutVertically(); rateRows(right,4,{code:13,value:17},null); } Script.setWidget(widget); if (!config.runsInWidget) { if(small) await widget.presentSmall(); else if(large) await widget.presentLarge(); else await widget.presentMedium(); } Script.complete();