/* * mygram-droid: Android TV client for Foldergram. * Copyright (C) 2026 Danilo M. * * This program is free software; you can redistribute it and/or modify it * under the terms of the GNU General Public License version 2 as published * by the Free Software Foundation. * * This program is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License * for more details. */ package mygram.droid; import android.app.Activity; import android.content.Context; import android.graphics.Color; import android.graphics.RenderEffect; import android.graphics.Shader; import android.graphics.drawable.GradientDrawable; import android.text.InputType; import android.view.Gravity; import android.view.View; import android.view.ViewGroup; import android.widget.EditText; import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.TextView; import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import com.bumptech.glide.Glide; import java.util.ArrayList; import java.util.List; /** View helpers. The UI is built in code, there are no layout files. */ final class Ui { static final int RED = 0xFFFF3B5C; /** Pink end of the app icon gradient. */ static final int ACCENT = 0xFFEC4899; /** Post tiles: portrait, most media is 9:16. */ static final int POST_W = 128, POST_H = 228; private Ui() { } static int dp(Context c, float v) { return Math.round(v * c.getResources().getDisplayMetrics().density); } static TextView text(Context c, String s, int sp) { TextView t = new TextView(c); t.setText(s); t.setTextSize(sp); t.setTextColor(Color.WHITE); return t; } static LinearLayout column(Context c) { LinearLayout box = new LinearLayout(c); box.setOrientation(LinearLayout.VERTICAL); box.setGravity(Gravity.CENTER); int p = dp(c, 32); box.setPadding(p, p, p, p); return box; } static LinearLayout row(Context c) { LinearLayout row = new LinearLayout(c); row.setGravity(Gravity.CENTER); return row; } static EditText field(Context c, String hint, int inputType) { EditText e = new EditText(c); e.setHint(hint); // setSingleLine() resets the input type's transformation, unmasking // passwords; it must come first. e.setSingleLine(true); e.setInputType(inputType); e.setTextColor(Color.WHITE); e.setHintTextColor(Color.GRAY); e.setMinWidth(dp(c, 400)); return e; } static int password() { return InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD; } /** Focus ring and a small zoom: the only focus feedback on a TV. */ static void focusable(View v) { v.setFocusable(true); v.setForeground(v.getContext().getDrawable(R.drawable.focus_ring)); v.setOnFocusChangeListener((x, has) -> zoom(x, has)); } private static void zoom(View v, boolean in) { v.animate().scaleX(in ? 1.08f : 1f).scaleY(in ? 1.08f : 1f).setDuration(120).start(); } /** * Icon-only button. Focus scales the icon and turns it accent pink, and * shows the label under it. Child 1 is the current-view mark. */ static LinearLayout iconButton(Context c, int icon, String label, View.OnClickListener click) { LinearLayout box = new LinearLayout(c); box.setOrientation(LinearLayout.VERTICAL); box.setGravity(Gravity.CENTER_HORIZONTAL); int p = dp(c, 10); box.setPadding(p, p, p, p / 2); box.setClipToPadding(false); // the focus zoom draws into the padding; the full-bleed logo was cut ImageView image = new ImageView(c); image.setImageResource(icon); image.setContentDescription(label); image.setAlpha(0.7f); int s = dp(c, 36); box.addView(image, new LinearLayout.LayoutParams(s, s)); GradientDrawable bar = new GradientDrawable(); bar.setColor(ACCENT); bar.setCornerRadius(dp(c, 2)); View mark = new View(c); mark.setBackground(bar); mark.setVisibility(View.INVISIBLE); LinearLayout.LayoutParams mp = new LinearLayout.LayoutParams(dp(c, 20), dp(c, 3)); mp.topMargin = dp(c, 8); // clears the 1.25 focus zoom box.addView(mark, mp); TextView t = text(c, label, 12); t.setSingleLine(true); t.setVisibility(View.INVISIBLE); // WRAP_CONTENT: the vertical default, MATCH_PARENT, does not widen the box, so long labels were clipped box.addView(t, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)); boolean tint = icon != R.drawable.ic_launcher; // the logo keeps its colours box.setFocusable(true); box.setOnFocusChangeListener((v, has) -> { t.setVisibility(has ? View.VISIBLE : View.INVISIBLE); image.setAlpha(has ? 1f : 0.7f); if (tint && has) image.setColorFilter(ACCENT); else image.clearColorFilter(); image.animate().scaleX(has ? 1.25f : 1f).scaleY(has ? 1.25f : 1f).setDuration(120).start(); }); box.setOnClickListener(click); return box; } /** Shows or hides the mark under an iconButton that stands for the current view. */ static void markCurrent(LinearLayout button, boolean current) { button.getChildAt(1).setVisibility(current ? View.VISIBLE : View.INVISIBLE); } /** Back arrow and title, for screens outside the main view. */ static LinearLayout header(Activity a, String title) { return header(a, title, null); } /** Back arrow, the folder picture when there is one, and title. */ static LinearLayout header(Activity a, String title, String avatarUrl) { LinearLayout header = new LinearLayout(a); header.setGravity(Gravity.CENTER_VERTICAL); int p = dp(a, 16); header.setPadding(p * 2, p, p * 2, 0); header.addView(iconButton(a, R.drawable.ic_arrow_back, "Back", v -> a.finish())); ImageView avatar = avatar(a, 40); ((LinearLayout.LayoutParams) avatar.getLayoutParams()).leftMargin = p; header.addView(avatar); setAvatar(avatar, avatarUrl); TextView t = text(a, title, 24); t.setPadding(p, 0, 0, 0); header.addView(t); return header; } /** A round folder picture of size dp, for a horizontal LinearLayout. */ static ImageView avatar(Context c, int size) { ImageView v = new ImageView(c); v.setLayoutParams(new LinearLayout.LayoutParams(dp(c, size), dp(c, size))); return v; } /** Loads url round, or hides the view when the folder has no picture. */ static void setAvatar(ImageView v, String url) { v.setVisibility(url == null ? View.GONE : View.VISIBLE); if (url == null) Glide.with(v).clear(v); else Glide.with(v).load(url).circleCrop().into(v); } static void blur(ImageView v, float radius) { v.setRenderEffect(RenderEffect.createBlurEffect(radius, radius, Shader.TileMode.CLAMP)); } static RecyclerView list(Context c, int orientation) { RecyclerView rv = new RecyclerView(c); rv.setLayoutManager(new LinearLayoutManager(c, orientation, false)); int p = dp(c, 24); rv.setPadding(p, 0, p, 0); rv.setClipToPadding(false); return rv; } static RecyclerView grid(Context c, List tiles, int w, int h, int span) { RecyclerView rv = new RecyclerView(c); rv.setLayoutManager(new GridLayoutManager(c, span)); rv.setAdapter(new TileAdapter(tiles, w, h)); int p = dp(c, 24); rv.setPadding(p, p, p, p); rv.setClipToPadding(false); return rv; } /** Tiles that open the viewer on posts, starting at the chosen one. */ static List postTiles(Context c, List posts) { List tiles = new ArrayList<>(); for (int i = 0; i < posts.size(); i++) { int start = i; tiles.add(TileAdapter.post(posts.get(i), () -> Playlist.open(c, posts, start, null))); } return tiles; } }