Files
petfeed/mobile/scripts/derive-adaptive-icon.js
T
admin a737a17976 fix(mobile): feed separation, adaptive launcher icon; feat: full pet profile
Fixes from user testing on a real device:
- Feed: replaced an invisible spacing-only separator with an actual
  hairline divider between posts.
- Android launcher icon showed Expo's default placeholder (a blue arrow)
  because the app icon and the Android adaptive-icon layers
  (foreground/background/monochrome) are two separate asset sets — only
  icon.png/favicon.png had been replaced. Added scripts/derive-adaptive-
  icon.js to crop the paw glyph out of icon.png (detected by its marigold
  color) and generate all three adaptive-icon layers plus the splash icon;
  this is an explicit placeholder per user's own call, not final art.

New: full pet profile (app/(app)/pets/[petId]/), matching web's
src/app/(app)/pets/[petId]/page.tsx:
- View: identity, species, bio/adoption story, follower/following counts
  (tappable → list screens), the pet's own posts below.
- Follow/unfollow with real initial state (follows.isFollowing) and
  optimistic toggle — unlike feed reactions, this endpoint actually
  exists, so no need to fake an initial state.
- Edit screen (name/nickname/bio/adoption story via pets.update) for the
  owner's own pets.
- Tapping a pet (feed header, Explore card, Pets-tab row) now opens this
  profile instead of nowhere; the Pets tab's active-pet switch is now a
  separate checkmark control instead of overloading the whole row tap.

PostCard's post-shape prop is now structural rather than tied to
feed.getFeed's output type, since posts.byPetId selects `pet` via the
narrower petIdentitySelect (no isVerified/owner) — both need to satisfy
the same component.

Verified end-to-end on-device: feed separator visible, own-pet profile
shows a working Bearbeiten button whose form is correctly prefilled (a
stray edit was made to Bianca's nickname field by mistake during testing
and was discarded via back-navigation without saving — never sent to the
server), someone else's pet profile shows a working Follow/Unfollow
toggle, followers list navigates correctly, and the home-screen launcher
icon now shows the paw instead of the default Expo icon.
2026-09-05 13:47:20 +02:00

103 lines
4.6 KiB
JavaScript

// One-off placeholder generator: crops the paw glyph out of assets/icon.png
// (detected by its marigold-orange color, since the glyph sits to the left
// of the "PawFeed" wordmark) and derives Android adaptive-icon layers from
// it. This is explicitly a stand-in — see mobile/README.md — real
// foreground/background/monochrome art should replace these.
const sharp = require("sharp");
const path = require("path");
const ASSETS = path.join(__dirname, "..", "assets");
const CANVAS = 1024;
const MARIGOLD = { r: 249, g: 115, b: 22 };
const PORCH_WHITE = "#fafafa";
function isMarigold(r, g, b) {
return Math.abs(r - MARIGOLD.r) < 40 && Math.abs(g - MARIGOLD.g) < 40 && Math.abs(b - MARIGOLD.b) < 40;
}
async function findGlyphBounds(iconPath) {
const { data, info } = await sharp(iconPath).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
let minX = info.width, minY = info.height, maxX = 0, maxY = 0;
for (let y = 0; y < info.height; y++) {
for (let x = 0; x < info.width; x++) {
const i = (y * info.width + x) * info.channels;
if (isMarigold(data[i], data[i + 1], data[i + 2])) {
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
}
return { left: minX, top: minY, width: maxX - minX + 1, height: maxY - minY + 1 };
}
// The crop still carries the icon's near-white background as opaque
// pixels — key those out so only the orange glyph stays visible, with a
// soft-edged alpha falloff so it doesn't look hard-cut at low resolution.
async function makeGlyphTransparent(croppedBuffer) {
const { data, info } = await sharp(croppedBuffer).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
for (let p = 0; p < data.length; p += info.channels) {
const r = data[p], g = data[p + 1], b = data[p + 2];
const distance = Math.abs(r - MARIGOLD.r) + Math.abs(g - MARIGOLD.g) + Math.abs(b - MARIGOLD.b);
data[p + 3] = distance < 60 ? 255 : distance < 140 ? 255 - Math.round(((distance - 60) / 80) * 255) : 0;
}
return sharp(data, { raw: { width: info.width, height: info.height, channels: info.channels } }).png().toBuffer();
}
async function main() {
const iconPath = path.join(ASSETS, "icon.png");
const bounds = await findGlyphBounds(iconPath);
const opaqueCrop = await sharp(iconPath).extract(bounds).toBuffer();
const glyph = await makeGlyphTransparent(opaqueCrop);
const glyphMeta = await sharp(glyph).metadata();
// Scale the glyph to ~45% of the canvas, centered — comfortably inside
// Android's adaptive-icon safe zone regardless of launcher mask shape.
const targetSize = Math.round(CANVAS * 0.45);
const scale = targetSize / Math.max(glyphMeta.width, glyphMeta.height);
const resizedWidth = Math.round(glyphMeta.width * scale);
const resizedHeight = Math.round(glyphMeta.height * scale);
const resizedGlyph = await sharp(glyph).resize(resizedWidth, resizedHeight).toBuffer();
const left = Math.round((CANVAS - resizedWidth) / 2);
const top = Math.round((CANVAS - resizedHeight) / 2);
// Foreground: the orange glyph, centered, transparent background.
await sharp({ create: { width: CANVAS, height: CANVAS, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } } })
.composite([{ input: resizedGlyph, left, top }])
.png()
.toFile(path.join(ASSETS, "android-icon-foreground.png"));
// Background: solid porch-white, matching the web app's canvas color.
await sharp({ create: { width: CANVAS, height: CANVAS, channels: 4, background: PORCH_WHITE } })
.png()
.toFile(path.join(ASSETS, "android-icon-background.png"));
// Monochrome: same glyph silhouette, recolored solid white (Android tints
// this layer itself for themed/Material You icons).
const whiteGlyph = await sharp(resizedGlyph)
.ensureAlpha()
.composite([{ input: Buffer.from([255, 255, 255, 255]), raw: { width: 1, height: 1, channels: 4 }, tile: true, blend: "in" }])
.toBuffer();
await sharp({ create: { width: CANVAS, height: CANVAS, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } } })
.composite([{ input: whiteGlyph, left, top }])
.png()
.toFile(path.join(ASSETS, "android-icon-monochrome.png"));
// Splash icon: same as foreground (transparent glyph), matches Expo's
// splash-screen convention of a centered logo on a solid background.
await sharp({ create: { width: CANVAS, height: CANVAS, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } } })
.composite([{ input: resizedGlyph, left, top }])
.png()
.toFile(path.join(ASSETS, "splash-icon.png"));
console.log("Derived adaptive icon layers from", bounds);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});