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.
103 lines
4.6 KiB
JavaScript
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);
|
|
});
|