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}