001// SPDX-License-Identifier: GPL-3.0-or-later
002// Copyright © 2026 Aurélien Pierre
003
004package com.aurelienpierre.sioul;
005
006import android.appwidget.AppWidgetManager;
007import android.content.Context;
008import android.content.res.Configuration;
009import android.os.Bundle;
010import android.text.TextPaint;
011import android.util.DisplayMetrics;
012import android.util.TypedValue;
013import android.view.View;
014import android.widget.RemoteViews;
015
016import org.json.JSONArray;
017import org.json.JSONObject;
018
019import java.util.ArrayList;
020import java.util.Arrays;
021import java.util.List;
022
023/**
024 * Sioul's full card on the home screen (HomeCard draws it; docs/android.md,
025 * "The card on the home screen"), from what Rust wrote (crates/sioul-app/src/
026 * homecard.rs). Today first: the date, the weather now and the next four hours
027 * on its row (the farthest left out first when they do not fit, never
028 * wrapped), the next morning, afternoon, evening and night, the next seven days, and
029 * Open-Meteo's credit. Then what the Porch has for you: a dose due, a code you
030 * asked for and its warning, reminders, the calls declined, two events at
031 * once. Then Now: what now is for, do-not-disturb, the next step, where you
032 * stopped. The mail and the agenda are on their own cards.
033 *
034 * As the card shrinks, its parts are left out, the least needed first, today
035 * kept longest: where you stopped, the step, two events at once, the calls,
036 * the reminders, do-not-disturb, the code, the dose, then the seven days and
037 * the parts of the day; the date, the weather now and what now is for stay.
038 * Lines.of is the pure part (org.json alone), checked on a computer's JVM
039 * (android/jvm-checks/CardCheck.java); views draws it.
040 */
041final class HomeCardToday
042{
043    /** The text lines, each a view of the layout. */
044    static final int DATE = 0, WEATHER_LINE = 1, CREDIT = 2, DOSE = 3, CODE = 4, WARNING = 5,
045                     REMINDER = 6, CALL = 9, OVERLAP = 11, NOTE = 12, STATUS = 13, DND = 14, STEP = 15, STOPPED = 16;
046    /** Three reminders and two calls at most: the card is no list. */
047    static final int REMINDERS = 3, CALLS = 2;
048    private static final int LINES = 17;
049    private static final int[] VIEWS = {
050        R.id.sioul_home_card_date, R.id.sioul_home_card_weather_line, R.id.sioul_home_card_credit,
051        R.id.sioul_home_card_dose, R.id.sioul_home_card_code, R.id.sioul_home_card_warning,
052        R.id.sioul_home_card_reminder0, R.id.sioul_home_card_reminder1, R.id.sioul_home_card_reminder2,
053        R.id.sioul_home_card_call0, R.id.sioul_home_card_call1, R.id.sioul_home_card_overlap,
054        R.id.sioul_home_card_note, R.id.sioul_home_card_status, R.id.sioul_home_card_dnd,
055        R.id.sioul_home_card_step, R.id.sioul_home_card_stopped,
056    };
057    /** Each line's text size (sp) and its most lines, as the layout has them. */
058    private static final float[] SIZES = { 16, 13, 10, 13, 13, 12, 13, 13, 13, 13, 13, 13, 13, 14, 13, 14, 13 };
059    private static final int[] MOST = { 1, 2, 1, 2, 1, 1, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 2 };
060    /** The space above each line (dp), as the layout has it. */
061    private static final float[] ABOVE = { 0, 4, 2, 0, 2, 0, 2, 2, 2, 2, 2, 2, 2, 0, 2, 2, 2 };
062    /**
063     * Kept while the card shrinks, the lowest first: today (the date, the
064     * weather now or the line said instead, what now is for) always; then
065     * the dose, a "not set up yet", the code, do-not-disturb, the reminders,
066     * the calls, two events at once, the step, where you stopped. The parts
067     * of the day and the seven days come before the dose (ranks 1 and 2).
068     */
069    private static final int[] RANKS = { 0, 0, 0, 3, 4, 4, 6, 6, 6, 7, 7, 8, 3, 0, 5, 9, 10 };
070    private static final int PARTS_RANK = 1, DAYS_RANK = 2, LAST_RANK = 10;
071
072    /** The next hours beside the weather now, four at most (weather.rs, CARD_HOURS). */
073    static final int HOURS_SHOWN = 4;
074    private static final int[] HOUR_CELLS = { R.id.sioul_home_card_hour0, R.id.sioul_home_card_hour1, R.id.sioul_home_card_hour2, R.id.sioul_home_card_hour3 };
075    private static final int[][] HOURS = {
076        { R.id.sioul_home_card_hour0_label, R.id.sioul_home_card_hour0_icon, R.id.sioul_home_card_hour0_temp, R.id.sioul_home_card_hour0_rain },
077        { R.id.sioul_home_card_hour1_label, R.id.sioul_home_card_hour1_icon, R.id.sioul_home_card_hour1_temp, R.id.sioul_home_card_hour1_rain },
078        { R.id.sioul_home_card_hour2_label, R.id.sioul_home_card_hour2_icon, R.id.sioul_home_card_hour2_temp, R.id.sioul_home_card_hour2_rain },
079        { R.id.sioul_home_card_hour3_label, R.id.sioul_home_card_hour3_icon, R.id.sioul_home_card_hour3_temp, R.id.sioul_home_card_hour3_rain },
080    };
081    /** An hour's cell, at its narrowest (the layout's minWidth), in dp. */
082    private static final int HOUR_DP = 48;
083    private static final int[][] PARTS = {
084        { R.id.sioul_home_card_part0, R.id.sioul_home_card_part0_label, R.id.sioul_home_card_part0_icon, R.id.sioul_home_card_part0_temp, R.id.sioul_home_card_part0_rain },
085        { R.id.sioul_home_card_part1, R.id.sioul_home_card_part1_label, R.id.sioul_home_card_part1_icon, R.id.sioul_home_card_part1_temp, R.id.sioul_home_card_part1_rain },
086        { R.id.sioul_home_card_part2, R.id.sioul_home_card_part2_label, R.id.sioul_home_card_part2_icon, R.id.sioul_home_card_part2_temp, R.id.sioul_home_card_part2_rain },
087        { R.id.sioul_home_card_part3, R.id.sioul_home_card_part3_label, R.id.sioul_home_card_part3_icon, R.id.sioul_home_card_part3_temp, R.id.sioul_home_card_part3_rain },
088    };
089    private static final int[][] DAYS = {
090        { R.id.sioul_home_card_day0, R.id.sioul_home_card_day0_label, R.id.sioul_home_card_day0_icon, R.id.sioul_home_card_day0_high, R.id.sioul_home_card_day0_low, R.id.sioul_home_card_day0_rain },
091        { R.id.sioul_home_card_day1, R.id.sioul_home_card_day1_label, R.id.sioul_home_card_day1_icon, R.id.sioul_home_card_day1_high, R.id.sioul_home_card_day1_low, R.id.sioul_home_card_day1_rain },
092        { R.id.sioul_home_card_day2, R.id.sioul_home_card_day2_label, R.id.sioul_home_card_day2_icon, R.id.sioul_home_card_day2_high, R.id.sioul_home_card_day2_low, R.id.sioul_home_card_day2_rain },
093        { R.id.sioul_home_card_day3, R.id.sioul_home_card_day3_label, R.id.sioul_home_card_day3_icon, R.id.sioul_home_card_day3_high, R.id.sioul_home_card_day3_low, R.id.sioul_home_card_day3_rain },
094        { R.id.sioul_home_card_day4, R.id.sioul_home_card_day4_label, R.id.sioul_home_card_day4_icon, R.id.sioul_home_card_day4_high, R.id.sioul_home_card_day4_low, R.id.sioul_home_card_day4_rain },
095        { R.id.sioul_home_card_day5, R.id.sioul_home_card_day5_label, R.id.sioul_home_card_day5_icon, R.id.sioul_home_card_day5_high, R.id.sioul_home_card_day5_low, R.id.sioul_home_card_day5_rain },
096        { R.id.sioul_home_card_day6, R.id.sioul_home_card_day6_label, R.id.sioul_home_card_day6_icon, R.id.sioul_home_card_day6_high, R.id.sioul_home_card_day6_low, R.id.sioul_home_card_day6_rain },
097    };
098    /** The weather's icons, as weather.rs names them (Breeze's), and their drawings (tools/make-weather-drawables.py). */
099    private static final String[] ICON_NAMES = {
100        "weather-clear-symbolic", "weather-clear-night-symbolic", "weather-few-clouds-symbolic", "weather-few-clouds-night-symbolic",
101        "weather-clouds-symbolic", "weather-clouds-night-symbolic", "weather-overcast-symbolic", "weather-fog-symbolic",
102        "weather-showers-scattered-day-symbolic", "weather-showers-scattered-night-symbolic", "weather-freezing-scattered-rain-symbolic",
103        "weather-showers-day-symbolic", "weather-showers-night-symbolic", "weather-showers-symbolic", "weather-freezing-rain-symbolic",
104        "weather-snow-day-symbolic", "weather-snow-night-symbolic", "weather-showers-scattered-symbolic", "weather-snow-scattered-symbolic",
105        "weather-storm-day-symbolic", "weather-storm-night-symbolic", "weather-hail-symbolic", "weather-none-available-symbolic",
106    };
107    private static final int[] ICONS = {
108        R.drawable.sioul_weather_clear, R.drawable.sioul_weather_clear_night, R.drawable.sioul_weather_few_clouds, R.drawable.sioul_weather_few_clouds_night,
109        R.drawable.sioul_weather_clouds, R.drawable.sioul_weather_clouds_night, R.drawable.sioul_weather_overcast, R.drawable.sioul_weather_fog,
110        R.drawable.sioul_weather_showers_scattered_day, R.drawable.sioul_weather_showers_scattered_night, R.drawable.sioul_weather_freezing_scattered_rain,
111        R.drawable.sioul_weather_showers_day, R.drawable.sioul_weather_showers_night, R.drawable.sioul_weather_showers, R.drawable.sioul_weather_freezing_rain,
112        R.drawable.sioul_weather_snow_day, R.drawable.sioul_weather_snow_night, R.drawable.sioul_weather_showers_scattered, R.drawable.sioul_weather_snow_scattered,
113        R.drawable.sioul_weather_storm_day, R.drawable.sioul_weather_storm_night, R.drawable.sioul_weather_hail, R.drawable.sioul_weather_none_available,
114    };
115
116    private HomeCardToday()
117    {
118    }
119
120    /** A cell of the weather: an hour, a part of the day, a day. */
121    static final class Cell
122    {
123        String label = "", icon = "", temperature = "", low = "", rain = "", words = "";
124
125        static Cell of(JSONObject slot)
126        {
127            Cell cell = new Cell();
128            if (slot == null)
129                return cell;
130            cell.label = slot.optString("label");
131            cell.icon = slot.optString("icon");
132            // An hour or a part says "temperature"; a day, "high" and "low".
133            cell.temperature = slot.has("high") ? slot.optString("high") : slot.optString("temperature");
134            cell.low = slot.optString("low");
135            cell.rain = slot.optString("rain");
136            cell.words = slot.optString("words");
137            return cell;
138        }
139    }
140
141    /** What the full card says now, and when that changes next: the pure part. */
142    static final class Lines
143    {
144        final String[] texts = new String[LINES];
145        /** Where a tap on each line leads (HomeCardOpener's address); "" opens Sioul as its icon does. */
146        final String[] opens = new String[LINES];
147        String age = "";
148        /** The weather now; none: no weather part. */
149        Cell now;
150        final List<Cell> hours = new ArrayList<>();
151        final List<Cell> parts = new ArrayList<>();
152        /** The days after today, seven at most. */
153        final List<Cell> days = new ArrayList<>();
154        /** When to draw again (Unix ms); never: Long.MAX_VALUE. */
155        long next = Long.MAX_VALUE;
156
157        Lines()
158        {
159            for (int i = 0; i < LINES; i++) {
160                texts[i] = "";
161                opens[i] = "";
162            }
163        }
164
165        private void soon(long at, long now)
166        {
167            if (at > now && at < next)
168                next = at;
169        }
170
171        /**
172         * The lines for `now`: today's, then the frame's (the Porch's, Now's).
173         * A card an older Sioul wrote (version 2) has no today and no
174         * reminders: its other lines show; none at all, Java's own words.
175         */
176        static Lines of(JSONObject card, long now)
177        {
178            Lines lines = new Lines();
179            if (card == null) {
180                // Sioul not opened since it was installed: its own words, Java's.
181                lines.texts[STATUS] = HomeCard.empty();
182                return lines;
183            }
184            lines.today(card.optJSONObject("today"), now);
185            JSONObject frame = HomeCard.frame(HomeCard.frames(card), now);
186            if (frame == null) {
187                // Past the last frame: Sioul was not opened for a day and more.
188                lines.texts[STATUS] = card.optString("beyond");
189            } else {
190                lines.soon(frame.optLong("until"), now);
191                lines.texts[STATUS] = frame.optString("status");
192                lines.texts[DND] = frame.optString("dnd");
193                lines.texts[NOTE] = frame.optString("note");
194                // The step's title alone: why it comes now is on Now, where a tap leads.
195                JSONObject step = frame.optJSONObject("step");
196                if (step != null) {
197                    lines.texts[STEP] = step.optString("title");
198                    lines.opens[STEP] = "now/" + step.optString("uid");
199                }
200                if (frame.optBoolean("stopped")) {
201                    lines.texts[STOPPED] = card.optString("stopped");
202                    lines.opens[STOPPED] = "now";
203                }
204                if (frame.optBoolean("codes"))
205                    lines.code(card.optJSONArray("codes"), now);
206                if (frame.optBoolean("doses"))
207                    lines.doses(card.optJSONArray("doses"), now);
208                lines.reminders(card.optJSONArray("reminders"), frame.optJSONArray("reminders"), now);
209                lines.calls(card.optJSONArray("calls"), frame.optJSONArray("calls"));
210                if (frame.optBoolean("overlaps"))
211                    lines.overlap(card.optJSONArray("overlaps"), now);
212            }
213            lines.age(card, now);
214            return lines;
215        }
216
217        /** The date, then the weather: the hour under way's, and the days after today. */
218        private void today(JSONObject today, long now)
219        {
220            if (today == null)
221                return;
222            JSONArray date = today.optJSONArray("date");
223            texts[DATE] = HomeCardRows.label(date, now);
224            soon(HomeCardRows.ahead(date, now), now);
225            JSONObject weather = today.optJSONObject("weather");
226            if (weather == null) {
227                texts[WEATHER_LINE] = today.optString("line");
228                return;
229            }
230            texts[CREDIT] = weather.optString("credit");
231            JSONObject hour = within(weather.optJSONArray("hours"), now);
232            if (hour != null) {
233                soon(hour.optLong("until"), now);
234                this.now = Cell.of(hour.optJSONObject("now"));
235                add(hours, hour.optJSONArray("next"), HOURS_SHOWN);
236                add(parts, hour.optJSONArray("parts"), 4);
237            }
238            // The days after today: from the end of the day now falls in.
239            JSONArray all = weather.optJSONArray("days");
240            JSONObject current = within(all, now);
241            long after = current == null ? now : current.optLong("until");
242            soon(after, now);
243            for (int i = 0; all != null && i < all.length() && days.size() < 7; i++) {
244                JSONObject day = all.optJSONObject(i);
245                if (day != null && day.optLong("from") >= after)
246                    days.add(Cell.of(day));
247            }
248            // No weather left to say (the forecast's hours and days are over): its credit goes with it.
249            if (this.now == null && days.isEmpty())
250                texts[CREDIT] = "";
251        }
252
253        private static void add(List<Cell> cells, JSONArray slots, int most)
254        {
255            for (int i = 0; slots != null && i < slots.length() && cells.size() < most; i++)
256                cells.add(Cell.of(slots.optJSONObject(i)));
257        }
258
259        /** The newest code still valid; the next to expire, the next drawing. */
260        private void code(JSONArray codes, long now)
261        {
262            for (int i = 0; codes != null && i < codes.length(); i++) {
263                JSONObject code = codes.optJSONObject(i);
264                long until = code == null ? 0 : code.optLong("until");
265                if (until <= now)
266                    continue;
267                soon(until, now);
268                if (texts[CODE].isEmpty()) {
269                    texts[CODE] = code.optString("line");
270                    texts[WARNING] = code.optString("warning");
271                    opens[CODE] = "porch";
272                    opens[WARNING] = "porch";
273                }
274            }
275        }
276
277        /**
278         * The doses due now, in one line: each as Rust said it while it is known
279         * not marked elsewhere, then with "check before taking it". Never more.
280         */
281        private void doses(JSONArray doses, long now)
282        {
283            StringBuilder line = new StringBuilder();
284            for (int i = 0; doses != null && i < doses.length(); i++) {
285                JSONObject dose = doses.optJSONObject(i);
286                if (dose == null)
287                    continue;
288                long from = dose.optLong("from"), until = dose.optLong("until"), known = dose.optLong("known_until");
289                soon(from, now);
290                if (from > now || until <= now)
291                    continue;
292                soon(until, now);
293                soon(known, now);
294                if (line.length() > 0)
295                    line.append("; ");
296                line.append(known > now ? dose.optString("line") : dose.optString("check"));
297            }
298            texts[DOSE] = line.toString();
299            opens[DOSE] = "porch";
300        }
301
302        /** The reminders the frame shows, each from its time while it makes sense; the same words once. */
303        private void reminders(JSONArray all, JSONArray shown, long now)
304        {
305            int at = 0;
306            for (int i = 0; all != null && shown != null && i < shown.length(); i++) {
307                JSONObject reminder = all.optJSONObject(shown.optInt(i, -1));
308                if (reminder == null)
309                    continue;
310                long from = reminder.optLong("from"), until = reminder.optLong("until");
311                soon(from, now);
312                if (from > now || until <= now)
313                    continue;
314                soon(until, now);
315                String line = reminder.optString("line");
316                if (at >= REMINDERS || line.isEmpty() || said(REMINDER, at, line))
317                    continue;
318                String open = reminder.optString("open");
319                texts[REMINDER + at] = line;
320                opens[REMINDER + at] = open.isEmpty() ? "porch" : "reminder/" + open;
321                at++;
322            }
323        }
324
325        /** Whether one of the first `count` lines from `first` says `line` already. */
326        private boolean said(int first, int count, String line)
327        {
328            for (int i = 0; i < count; i++)
329                if (texts[first + i].equals(line))
330                    return true;
331            return false;
332        }
333
334        /** The calls declined the frame lists, in the Porch's words. */
335        private void calls(JSONArray all, JSONArray listed)
336        {
337            int at = 0;
338            for (int i = 0; all != null && listed != null && i < listed.length() && at < CALLS; i++) {
339                String line = all.optString(listed.optInt(i, -1), "");
340                if (line.isEmpty() || said(CALL, at, line))
341                    continue;
342                texts[CALL + at] = line;
343                opens[CALL + at] = "porch";
344                at++;
345            }
346        }
347
348        /** Two events at once, on their day until both are over. */
349        private void overlap(JSONArray overlaps, long now)
350        {
351            for (int i = 0; overlaps != null && i < overlaps.length(); i++) {
352                JSONObject overlap = overlaps.optJSONObject(i);
353                if (overlap == null)
354                    continue;
355                long from = overlap.optLong("from"), until = overlap.optLong("until");
356                soon(from, now);
357                if (from > now || until <= now)
358                    continue;
359                soon(until, now);
360                if (texts[OVERLAP].isEmpty()) {
361                    texts[OVERLAP] = overlap.optString("line");
362                    opens[OVERLAP] = "porch";
363                }
364            }
365        }
366
367        /** "at 14:05" once the card is a quarter of an hour old. */
368        private void age(JSONObject card, long now)
369        {
370            long after = card.optLong("stale_after");
371            if (now < after) {
372                soon(after, now);
373                return;
374            }
375            JSONArray labels = card.optJSONArray("stale");
376            age = HomeCardRows.label(labels, now);
377            soon(HomeCardRows.ahead(labels, now), now);
378        }
379    }
380
381    /** The item `now` falls in, among items each {from, until}; none. */
382    static JSONObject within(JSONArray items, long now)
383    {
384        for (int i = 0; items != null && i < items.length(); i++) {
385            JSONObject item = items.optJSONObject(i);
386            if (item != null && item.optLong("from") <= now && now < item.optLong("until"))
387                return item;
388        }
389        return null;
390    }
391
392    /** A weather icon's drawing, by weather.rs's name; one it does not know, "none available". */
393    static int icon(String name)
394    {
395        for (int i = 0; i < ICON_NAMES.length; i++)
396            if (ICON_NAMES[i].equals(name))
397                return ICONS[i];
398        return R.drawable.sioul_weather_none_available;
399    }
400
401    // ---------------------------------------------------------------- drawn
402
403    /**
404     * The full card at its size: each part kept while it fits, the most
405     * needed first (RANKS), and its taps ({@link HomeCard#opener}, through
406     * HomeCardOpener); the rest opens Sioul as its icon does.
407     */
408    static RemoteViews views(Context context, Lines lines, Bundle options)
409    {
410        RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.sioul_home_card_today);
411        DisplayMetrics metrics = context.getResources().getDisplayMetrics();
412        boolean upright = context.getResources().getConfiguration().orientation != Configuration.ORIENTATION_LANDSCAPE;
413        int widthDp = options == null ? 0 : options.getInt(upright ? AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH : AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH);
414        int heightDp = options == null ? 0 : options.getInt(upright ? AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT : AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT);
415        // Not said yet (a card being placed): about four cells by four.
416        if (widthDp <= 0)
417            widthDp = 300;
418        if (heightDp <= 0)
419            heightDp = 300;
420        Fit fit = new Fit(lines, metrics, widthDp, heightDp);
421
422        for (int i = 0; i < LINES; i++) {
423            boolean shown = fit.kept[i];
424            views.setTextViewText(VIEWS[i], shown ? lines.texts[i] : "");
425            views.setViewVisibility(VIEWS[i], shown ? View.VISIBLE : View.GONE);
426            if (shown && !lines.opens[i].isEmpty())
427                views.setOnClickPendingIntent(VIEWS[i], HomeCard.opener(context, lines.opens[i]));
428        }
429        views.setTextViewText(R.id.sioul_home_card_age, lines.age);
430        views.setViewVisibility(R.id.sioul_home_card_age, lines.age.isEmpty() ? View.GONE : View.VISIBLE);
431
432        // The weather now, and the next hours beside it on the same row: as many as fit, the nearest first.
433        views.setViewVisibility(R.id.sioul_home_card_now, lines.now == null ? View.GONE : View.VISIBLE);
434        if (lines.now != null) {
435            views.setImageViewResource(R.id.sioul_home_card_now_icon, icon(lines.now.icon));
436            views.setContentDescription(R.id.sioul_home_card_now, join(lines.now.words, lines.now.temperature, lines.now.rain));
437            views.setTextViewText(R.id.sioul_home_card_now_temp, lines.now.temperature);
438            // The chance of rain alone under it, as the hours have it: its words would push them off the row.
439            views.setTextViewText(R.id.sioul_home_card_now_rain, lines.now.rain);
440            views.setViewVisibility(R.id.sioul_home_card_hours, fit.hours > 0 ? View.VISIBLE : View.GONE);
441            for (int i = 0; i < HOURS.length; i++) {
442                boolean shown = i < fit.hours;
443                views.setViewVisibility(HOUR_CELLS[i], shown ? View.VISIBLE : View.GONE);
444                if (shown)
445                    cell(views, HOURS[i], lines.hours.get(i), false);
446            }
447        }
448        views.setViewVisibility(R.id.sioul_home_card_parts, fit.parts ? View.VISIBLE : View.GONE);
449        for (int i = 0; i < PARTS.length; i++) {
450            boolean shown = fit.parts && i < lines.parts.size();
451            views.setViewVisibility(PARTS[i][0], shown ? View.VISIBLE : View.GONE);
452            if (shown)
453                cell(views, Arrays.copyOfRange(PARTS[i], 1, 5), lines.parts.get(i), false);
454        }
455        views.setViewVisibility(R.id.sioul_home_card_days, fit.days > 0 ? View.VISIBLE : View.GONE);
456        for (int i = 0; i < DAYS.length; i++) {
457            boolean shown = i < fit.days;
458            views.setViewVisibility(DAYS[i][0], shown ? View.VISIBLE : View.GONE);
459            if (shown)
460                cell(views, Arrays.copyOfRange(DAYS[i], 1, 6), lines.days.get(i), true);
461        }
462        // The Porch's part, its space above it only when it says something.
463        boolean porch = false;
464        for (int i = DOSE; i <= NOTE; i++)
465            porch |= fit.kept[i];
466        views.setViewVisibility(R.id.sioul_home_card_porch, porch ? View.VISIBLE : View.GONE);
467        views.setOnClickPendingIntent(android.R.id.background, HomeCard.launch(context));
468        return views;
469    }
470
471    /** A cell's views (label, icon, temperature, [low,] rain) said, and its words for screen readers. */
472    private static void cell(RemoteViews views, int[] ids, Cell cell, boolean day)
473    {
474        views.setTextViewText(ids[0], cell.label);
475        views.setImageViewResource(ids[1], icon(cell.icon));
476        views.setContentDescription(ids[1], cell.words);
477        views.setTextViewText(ids[2], cell.temperature);
478        if (day) {
479            views.setTextViewText(ids[3], cell.low);
480            views.setTextViewText(ids[4], cell.rain);
481        } else {
482            views.setTextViewText(ids[3], cell.rain);
483        }
484    }
485
486    /** Some words for screen readers, the empty ones left out: "Rain, 16°, 98 %". */
487    private static String join(String... parts)
488    {
489        StringBuilder said = new StringBuilder();
490        for (String part : parts) {
491            if (part.isEmpty())
492                continue;
493            if (said.length() > 0)
494                said.append(", ");
495            said.append(part);
496        }
497        return said.toString();
498    }
499
500    /** What fits in the card's height, the most needed first; and what its width allows. */
501    private static final class Fit
502    {
503        final boolean[] kept = new boolean[LINES];
504        boolean parts;
505        /** The next hours shown beside the weather now; the days shown. */
506        int hours, days;
507
508        Fit(Lines lines, DisplayMetrics metrics, int widthDp, int heightDp)
509        {
510            float density = metrics.density;
511            float width = (widthDp - 32) * density;
512            // Beside the weather now (its icon, its temperature or its chance of rain, the
513            // widest), as many hours as fit, the farthest left out first: never a second row.
514            if (lines.now != null) {
515                float now = (36 + 8 + 8) * density + Math.max(paint(24, metrics).measureText(lines.now.temperature), paint(12, metrics).measureText(lines.now.rain));
516                hours = Math.max(0, Math.min(Math.min(HOURS_SHOWN, lines.hours.size()), (int) Math.floor((width - now) / (HOUR_DP * density))));
517            }
518            // Seven days of 36 dp at least; fewer on a narrow card, four at least.
519            int columns = Math.max(4, Math.min(7, (widthDp - 32) / 36));
520            float room = (heightDp - 22) * density;
521            // Now's part is always there (what now is for), its space above it too.
522            float used = 10 * density;
523            // The weather now: its row, as tall as its hours' cells when they show.
524            if (lines.now != null) {
525                float hour = line(11, metrics) + 22 * density + line(13, metrics) + line(11, metrics);
526                float alone = Math.max(36 * density, line(24, metrics) + line(12, metrics));
527                used += (hours > 0 ? Math.max(hour, alone) : alone) + 6 * density;
528            }
529            boolean porchGap = false;
530            for (int rank = 0; rank <= LAST_RANK; rank++) {
531                if (rank == PARTS_RANK && !lines.parts.isEmpty()) {
532                    float height = line(11, metrics) + 22 * density + line(12, metrics) + line(11, metrics) + 8 * density;
533                    if (used + height <= room) {
534                        used += height;
535                        parts = true;
536                    }
537                }
538                if (rank == DAYS_RANK && !lines.days.isEmpty()) {
539                    float height = line(11, metrics) + 20 * density + line(12, metrics) + line(11, metrics) + line(10, metrics) + 8 * density;
540                    if (used + height <= room) {
541                        used += height;
542                        days = Math.min(columns, lines.days.size());
543                    }
544                }
545                for (int i = 0; i < LINES; i++) {
546                    if (RANKS[i] != rank || lines.texts[i].isEmpty())
547                        continue;
548                    // The warning goes with its code.
549                    if (i == WARNING && !kept[CODE])
550                        continue;
551                    float height = lines(lines.texts[i], i, metrics, width) + ABOVE[i] * density;
552                    boolean porch = i >= DOSE && i <= NOTE;
553                    if (porch && !porchGap)
554                        height += 10 * density;
555                    // Today's lines and what now is for stay, whatever the height.
556                    if (rank > 0 && used + height > room)
557                        continue;
558                    used += height;
559                    kept[i] = true;
560                    porchGap |= porch;
561                }
562            }
563        }
564
565        /** A line's height, its text wrapped on its most lines. */
566        private static float lines(String text, int i, DisplayMetrics metrics, float width)
567        {
568            TextPaint paint = paint(SIZES[i], metrics);
569            int count = width <= 0 ? 1 : (int) Math.ceil(paint.measureText(text) / width);
570            return paint.getFontSpacing() * Math.max(1, Math.min(MOST[i], count));
571        }
572
573        private static float line(float sp, DisplayMetrics metrics)
574        {
575            return paint(sp, metrics).getFontSpacing();
576        }
577
578        private static TextPaint paint(float sp, DisplayMetrics metrics)
579        {
580            TextPaint paint = new TextPaint(TextPaint.ANTI_ALIAS_FLAG);
581            paint.setTextSize(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, sp, metrics));
582            return paint;
583        }
584    }
585}