diff --git a/.gitignore b/.gitignore
index 49b1434..b5ac508 100644
--- a/.gitignore
+++ b/.gitignore
@@ -131,3 +131,6 @@ tmp_window_dump.xml
/docs/automation/fresh_2026-05-25_automation/ui-dumps
/docs/automation/screenshots
/docs/automation/ui-dumps
+/PetFolio-UI-Screens
+/PetFolio Redesign/screenshots
+/PetFolio Redesign/ref
diff --git a/.remember/remember.md b/.remember/remember.md
index e69de29..7e9bc5d 100644
--- a/.remember/remember.md
+++ b/.remember/remember.md
@@ -0,0 +1,40 @@
+# Petfolio — Remembered High-Signal Context
+
+This file contains high-signal architectural, structural, and feature-related context for subsequent development phases.
+
+## 1. Social & Feed Architecture
+- **Routes & Separation**:
+ - `CreatePostScreen` is mapped to `/social/create-post`.
+ - `CreateStoryScreen` is mapped to `/social/create-story`.
+- **Viewfinder & Grid Selection (Stories)**:
+ - Custom camera viewfinder (`_CameraViewfinderCard`) utilizes a DSLR/mobile viewfinder custom painter overlay with interactive mock triggers (rec dots, raw/hdr badges).
+ - Story selection is media-first, displaying a 3-column mock grid with high-resolution animal photography and a system gallery file-picker trigger.
+ - Media captures/selections are rendered on a fullscreen 9:16 story preview overlay with gradient-protected overlay texts.
+- **Instagram Aspect Ratio Standard**:
+ - The feed card (`_PostPhoto` in [social_screen.dart](file:///home/kratzer/workspace/petfolio/lib/features/social/presentation/screens/social_screen.dart)) and creation preview (`_ImagePreview` in [create_post_screen.dart](file:///home/kratzer/workspace/petfolio/lib/features/social/presentation/screens/create_post_screen.dart)) use a uniform `4:5` portrait aspect ratio (`aspectRatio: 4 / 5`).
+ - This matches the `4:5` aspect ratio used on the post detail screen (`post_detail_screen.dart`) to minimize vertical cropping.
+
+
+## 2. Interactive Story Features
+- **Story Long-Press Menu**:
+ - Long-pressing a story avatar in the home feed triggers a dynamic Cupertino/Material styled popup selector menu containing **Add to Story** and **View Story**.
+ - **Add to Story** immediately pushes the user to `/social/create-story`.
+ - **View Story** launches the full screen story viewer.
+- **Story Viewer Profile Click**:
+ - Tapping the avatar or pet name in `story_viewer_screen.dart` pauses the slide progression timer and navigates to the pet's profile page (`/social/profile/:petId`).
+ - Safely resumes the slide progress bar when the user returns via back navigation.
+
+
+## 3. General Architecture & Guidelines
+- **State Management**: Standardized entirely on Riverpod. Avoid introducing legacy packages like `provider`.
+- **Persistent Dark Mode**:
+ - The application uses a Riverpod code-generated `ThemeNotifier` (generating `themeProvider`) in [theme_notifier.dart](file:///home/kratzer/workspace/petfolio/lib/core/theme/theme_notifier.dart).
+ - Selected theme states are written to and loaded from `SharedPreferences` under the key `'theme_mode'`.
+ - Toggled using `ref.read(themeProvider.notifier).toggleTheme()` via the AppHeader action button inside [pet_profile_screen.dart](file:///home/kratzer/workspace/petfolio/lib/features/pet_profile/presentation/screens/pet_profile_screen.dart).
+- **Paw Icon Likes**:
+ - Replaced all default Material heart/favorite icons with paw icons (`Icons.pets_rounded` for liked/filled state and `Icons.pets_outlined` for unliked/border state) across social cards, details, and double-tap gestures to align with the pet-centric design.
+- **Supabase Optimization**:
+ - Push complex joins/aggregations to Database Views or RPCs to avoid client-side N+1 queries.
+ - Wrap database auth checks in subselects: `(select auth.uid())` for RLS performance.
+- **Errors & UI Alerts**:
+ - App-wide notifier-triggered failures must utilize `AppSnackBar.showError` and `appSnackBarMessengerKey` instead of assigning transient errors to long-lived state providers.
diff --git a/Design System.html b/Design System.html
new file mode 100644
index 0000000..e8e0a72
--- /dev/null
+++ b/Design System.html
@@ -0,0 +1,1593 @@
+
+
+
+
+PetFolio — Design System
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
PetFolioDesign System
+
+
+ Principles
+ Color
+ Typography
+ Radius
+ Elevation
+ Spacing
+ Motion
+ Buttons
+ Cards
+ Avatars & Species
+ Controls
+ Navigation
+ Iconography
+ Motifs
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PetFolio · Redesign · v1
+
+ A pet super-app that feelscuddly, confident, and quietly serious.
+ One warm cream surface, six pop accents, and chunky pill buttons that feel toy-like in the hand. This system documents the redesign that ships across Social, Match, Care, Health, and Market.
+
+ Type · Nunito + Fraunces
+ Platform · Android · iOS · Web
+ Pillars · 5
+ Pet classes · 6
+
+
+
+
+
+
+ 01
+
Principles — warm, but grown-up.
+
+ Pets are emotional. The redesign embraces that with rounded shapes, warm cream surfaces, and a playful conic-gradient avatar ring — without ever tipping into cartoon territory. Type stays grounded, hierarchy stays clear, and every primary action is sized for one-handed use on a walk.
+
+
+
+
01
+
Cream is the canvas
+
Cream (#FFF4E6) is the default background — never pure white. It signals warmth before any pixel of content loads, and it lets the six pop accents sing without fighting the surface.
+
+
+
02
+
Pillars wear different colors
+
Tangerine for Pets, Sunny for Care, Poppy for Social, Lilac for Match, Mint for Market. Each pillar's bottom-nav tab and section accents shift accordingly — the rest of the system stays constant.
+
+
+
03
+
Chunky > sleek
+
Pill buttons get a 6 px hard underline shadow that compresses on press, plus a soft halo. The result feels physical — closer to a toy than a UI element. Use it for every primary affordance.
+
+
+
04
+
Fraunces for feeling, Nunito for facts
+
Display headlines lean on Fraunces italic ("Mochi is feeling cuddly today "). Everything else — metrics, timestamps, captions — is Nunito. The contrast is the whole personality.
+
+
+
+
+
+
+
+ 02
+
Color — cream & six pops.
+
+ The palette is built around a warm cream surface and six saturated accents. Each accent has a `--soft` tint for chip backgrounds, a base for primary fills, and a `-700` shade for text-on-tint and pressed states.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Tangerine ramp — same pattern for all 6 accents
+
+
+
+
+
+
+
+
+
+
+
+
+
success
+
Quest done · vaccination up to date · order delivered
+
+
#2FB174
+
+
+
+
+
warning
+
Medication due · low stock
+
+
#F0A23A
+
+
+
+
+
danger
+
Vet alert · missed dose · listing removed
+
+
#E5484D
+
+
+
+
+
info
+
Tips · neutral system messages
+
+
#6EC8FF
+
+
+
+
+
+
+
+
+
+
+
+ 03
+
Typography — two voices.
+
+ Fraunces (display, italic-leaning) provides emotional moments — greetings, hero copy, mood statements. Nunito (UI, 400–900) carries everything else with a friendly round terminal and excellent legibility at small sizes.
+
+
+
+
Aa
+
Fraunces — display
+
Variable serif with a soft, optically-aware italic. Used at 24 px+ for hero copy, section poetry, and quoted moments. Never below 18 px.
+
+ Medium 500 Bold 700 Italic 500 Italic 700
+
+
+
+
Aa
+
Nunito — UI & body
+
Rounded sans with a wide x-height. Used for everything — metrics, captions, button labels, navigation. Goes heavy (800–900) for emphasis.
+
+ Regular 400 Medium 500 SemiBold 600 Bold 700 ExtraBold 800 Black 900
+
+
+
+
+
+
+
Display XL Fraunces · 700 · italic mix
+
Mochi is feeling cuddly
+
44 / 46 tracking −2.5%
+
+
+
Display Fraunces · 700
+
Find your pet's best friend
+
32 / 35 tracking −2%
+
+
+
Headline Nunito · 900
+
Today's quests
+
24 / 28 tracking −0.5%
+
+
+
Title Nunito · 800
+
Heartworm pill · Due 12:00 PM
+
18 / 22 tracking 0
+
+
+
Body L Nunito · 600
+
Mochi has a 7-day streak — keep it going by logging dinner before 8 PM.
+
16 / 23 tracking 0
+
+
+
Body Nunito · 500
+
Add a play date, log a vet visit, or browse treats. PetFolio learns Mochi's routine and suggests gentle nudges — never alarms.
+
15 / 22 tracking 0
+
+
+
Caption Nunito · 700
+
Due 12:00 PM · Indoor cat
+
12 / 16 tracking +1%
+
+
+
Overline Nunito · 800
+
Active pet · Mochi
+
11 / 14 tracking +18%
+
+
+
Numeric JetBrains Mono · 500
+
482 XP · 7-day · $24.50
+
22 / 26 tabular
+
+
+
+
+
+
+
+ 04
+
Radius — everything is squircle.
+
+ The system is generous with rounding. Buttons are full pills. Cards are squircles. Avatars are perfect circles. Nothing has a sharp 90° corner that isn't a status bar.
+
+
+ ★ xl 28 px is the default card radius. Pill is used for every button and chip. Avatars use a perfect circle (`50%`).
+
+
+
+
+
+ 05
+
Elevation — soft, warm shadows.
+
+ Shadows are warm-tinted (`rgba(120, 60, 20, ...)`) rather than neutral black, which keeps them rooted in the cream surface. The hero primary button uses a unique stacked shadow — a hard color bar underneath + a soft halo.
+
+
+
+
+
+
e1 — shadow-card
Default card
+
+
+
+
e2 — shadow-soft
Icon buttons · sheets
+
+
+
+
e3 — shadow-pop
Hero card · CTAs
+
+
+
+
+
Primary button stack
+
The signature treatment. A 6 px solid color bar plus a tinted halo — compresses to 2 px on press.
+
+ Get started
+ Find a match
+ Add to cart
+ Schedule visit
+
+
+
+
+
+
+
+ 06
+
Spacing — 4 dp base.
+
+ Spacing follows a 4 dp base. Card padding is 16–22 dp. Bottom-nav lives 10 dp from the bottom safe area. Tap targets are never below 44 dp; primary affordances target 52 dp.
+
+
+
+
+
+
+
+ 07
+
Motion — bouncy, not springy.
+
+ Motion leans on a single keyframe family — `pop-in`, `float-up`, `tail-wag`, `wiggle`, `bounce-soft`, `confetti-fall`. Easings prefer overshoot for confirmation moments (toggle knob, milestone), simple ease-out for transitions.
+
+
+
duration/xs 80 ms Tap feedback · button compress
+
duration/sm 140 ms Hover · color tween
+
duration/md 220 ms Page slide · sheet present
+
duration/lg 320 ms Pop-in · milestone confirm
+
duration/xl 500 ms Loader cycles · reaction burst
+
easing/bounce cubic(.5, 1.7, .5, 1) Toggle knob · paw confirm
+
easing/standard cubic(.2, .8, .2, 1) Default sheet motion
+
+
+
+
+
+
+
+
+
+ 09
+
Cards — squircles with breath.
+
+ Cards are 28 px squircles with a 1 px warm hairline and the standard card shadow. Inside, content gets 18 dp of breathing room. Quest rows compress to a 22 px squircle and `e1`.
+
+
+
+
+
+
+
+
Morning meal
+
8:00 AM · Done
+
+
+10 ★
+
+
+
+
+
+
+
+
Heartworm pill
+
Due 12:00 PM
+
+
+20 ★
+
+
+
+
+
+
Tooth brush Weekly
+
Bedtime
+
+
+25 ★
+
+
+
+
+
+
+
+
+
+
+ 🔥 7-day
+ 💖 New match
+ ✓ Up to date
+ Weekly
+ +10 XP
+
+
+
+
+
+
+
+
+ 10
+
Avatars & species — radial gradients + emoji.
+
+ Pet avatars are perfect circles filled with a radial gradient from the species `--soft` tint into the species base color, topped by the species emoji. The active-pet avatar gets a conic-gradient ring made from the four primary accents.
+
+
+
+
+
+
+
+
+
+
+
+ 11
+
Controls — paws and bones.
+
+ Two custom controls give the system its toy-like feeling. The Paw toggle — a switch whose knob carries a paw icon. The Bone slider — a range whose thumb is a bone shape, tilted 15°.
+
+
+
+
Paw toggle
+
54 × 30 with a 24 px round knob. The paw inside flips from gray to accent on activation.
+
+
+
+
+
+
+
Notifications on
+
+
+
+
+
+
Quiet hours off
+
+
+
+
+
Bone slider
+
Track gradient tangerine → poppy; thumb is a 4-circle + bar bone shape.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Indoor only
+ 10 km
+
+
+
+
+
+
+
+
+ 12
+
Navigation — 5 tabs, pillar-tinted.
+
+ A floating bottom-nav lives 10 dp from the home indicator. The active tab's icon gets a translucent pill background in the pillar accent. Inactive tabs are 600-weight, active tabs 800-weight.
+
+
+
+
+ Pets
+
+
+
+ Care
+
+
+
+ Social
+
+
+
+ Match
+
+
+
+ Market
+
+
+ Each tab also carries a 3 px pillar-colored underline below the label when active — a quiet reminder of the current pillar.
+
+
+
+
+
+ 13
+
Iconography — stroke + filled.
+
+ All icons are drawn on a 24 px grid with 1.9 px stroke, round caps, round joins. Two weights: stroke for inactive states, filled for active states and selection. Pet-class icons are emoji (intentional — they carry color and warmth that bespoke vector cannot).
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
pill
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 14
+
Motifs — waves, paws, confetti.
+
+ Four recurring decorative elements give the system its personality across screens. None of them are required — they're invited in for moments that earn them.
+
+
+
+
+
+
+
Active pet
+ Mochi
is feeling cuddly
+
+
+
Used as the top of every pet-detail and pillar landing screen.
+
+
+
+
+
+
+
Used behind onboarding, empty states, and milestone celebrations. Mixed accent colors at 16–22% opacity.
+
+
+
+
+
+
+
+
🐾💖🦴⭐🐾
+
Milestone unlocked
+
Paws, hearts, treats, and stars float up from the tap point in a single 900 ms burst.
+
+
+
+
+
+
+ PETFOLIO · DESIGN SYSTEM · V1 · CREAM + SIX POPS
+
+
+
+
+
+
+
diff --git a/PetFolio Redesign/Design System.html b/PetFolio Redesign/Design System.html
new file mode 100644
index 0000000..e8e0a72
--- /dev/null
+++ b/PetFolio Redesign/Design System.html
@@ -0,0 +1,1593 @@
+
+
+
+
+PetFolio — Design System
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
PetFolioDesign System
+
+
+ Principles
+ Color
+ Typography
+ Radius
+ Elevation
+ Spacing
+ Motion
+ Buttons
+ Cards
+ Avatars & Species
+ Controls
+ Navigation
+ Iconography
+ Motifs
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PetFolio · Redesign · v1
+
+ A pet super-app that feelscuddly, confident, and quietly serious.
+ One warm cream surface, six pop accents, and chunky pill buttons that feel toy-like in the hand. This system documents the redesign that ships across Social, Match, Care, Health, and Market.
+
+ Type · Nunito + Fraunces
+ Platform · Android · iOS · Web
+ Pillars · 5
+ Pet classes · 6
+
+
+
+
+
+
+ 01
+
Principles — warm, but grown-up.
+
+ Pets are emotional. The redesign embraces that with rounded shapes, warm cream surfaces, and a playful conic-gradient avatar ring — without ever tipping into cartoon territory. Type stays grounded, hierarchy stays clear, and every primary action is sized for one-handed use on a walk.
+
+
+
+
01
+
Cream is the canvas
+
Cream (#FFF4E6) is the default background — never pure white. It signals warmth before any pixel of content loads, and it lets the six pop accents sing without fighting the surface.
+
+
+
02
+
Pillars wear different colors
+
Tangerine for Pets, Sunny for Care, Poppy for Social, Lilac for Match, Mint for Market. Each pillar's bottom-nav tab and section accents shift accordingly — the rest of the system stays constant.
+
+
+
03
+
Chunky > sleek
+
Pill buttons get a 6 px hard underline shadow that compresses on press, plus a soft halo. The result feels physical — closer to a toy than a UI element. Use it for every primary affordance.
+
+
+
04
+
Fraunces for feeling, Nunito for facts
+
Display headlines lean on Fraunces italic ("Mochi is feeling cuddly today "). Everything else — metrics, timestamps, captions — is Nunito. The contrast is the whole personality.
+
+
+
+
+
+
+
+ 02
+
Color — cream & six pops.
+
+ The palette is built around a warm cream surface and six saturated accents. Each accent has a `--soft` tint for chip backgrounds, a base for primary fills, and a `-700` shade for text-on-tint and pressed states.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Tangerine ramp — same pattern for all 6 accents
+
+
+
+
+
+
+
+
+
+
+
+
+
success
+
Quest done · vaccination up to date · order delivered
+
+
#2FB174
+
+
+
+
+
warning
+
Medication due · low stock
+
+
#F0A23A
+
+
+
+
+
danger
+
Vet alert · missed dose · listing removed
+
+
#E5484D
+
+
+
+
+
info
+
Tips · neutral system messages
+
+
#6EC8FF
+
+
+
+
+
+
+
+
+
+
+
+ 03
+
Typography — two voices.
+
+ Fraunces (display, italic-leaning) provides emotional moments — greetings, hero copy, mood statements. Nunito (UI, 400–900) carries everything else with a friendly round terminal and excellent legibility at small sizes.
+
+
+
+
Aa
+
Fraunces — display
+
Variable serif with a soft, optically-aware italic. Used at 24 px+ for hero copy, section poetry, and quoted moments. Never below 18 px.
+
+ Medium 500 Bold 700 Italic 500 Italic 700
+
+
+
+
Aa
+
Nunito — UI & body
+
Rounded sans with a wide x-height. Used for everything — metrics, captions, button labels, navigation. Goes heavy (800–900) for emphasis.
+
+ Regular 400 Medium 500 SemiBold 600 Bold 700 ExtraBold 800 Black 900
+
+
+
+
+
+
+
Display XL Fraunces · 700 · italic mix
+
Mochi is feeling cuddly
+
44 / 46 tracking −2.5%
+
+
+
Display Fraunces · 700
+
Find your pet's best friend
+
32 / 35 tracking −2%
+
+
+
Headline Nunito · 900
+
Today's quests
+
24 / 28 tracking −0.5%
+
+
+
Title Nunito · 800
+
Heartworm pill · Due 12:00 PM
+
18 / 22 tracking 0
+
+
+
Body L Nunito · 600
+
Mochi has a 7-day streak — keep it going by logging dinner before 8 PM.
+
16 / 23 tracking 0
+
+
+
Body Nunito · 500
+
Add a play date, log a vet visit, or browse treats. PetFolio learns Mochi's routine and suggests gentle nudges — never alarms.
+
15 / 22 tracking 0
+
+
+
Caption Nunito · 700
+
Due 12:00 PM · Indoor cat
+
12 / 16 tracking +1%
+
+
+
Overline Nunito · 800
+
Active pet · Mochi
+
11 / 14 tracking +18%
+
+
+
Numeric JetBrains Mono · 500
+
482 XP · 7-day · $24.50
+
22 / 26 tabular
+
+
+
+
+
+
+
+ 04
+
Radius — everything is squircle.
+
+ The system is generous with rounding. Buttons are full pills. Cards are squircles. Avatars are perfect circles. Nothing has a sharp 90° corner that isn't a status bar.
+
+
+ ★ xl 28 px is the default card radius. Pill is used for every button and chip. Avatars use a perfect circle (`50%`).
+
+
+
+
+
+ 05
+
Elevation — soft, warm shadows.
+
+ Shadows are warm-tinted (`rgba(120, 60, 20, ...)`) rather than neutral black, which keeps them rooted in the cream surface. The hero primary button uses a unique stacked shadow — a hard color bar underneath + a soft halo.
+
+
+
+
+
+
e1 — shadow-card
Default card
+
+
+
+
e2 — shadow-soft
Icon buttons · sheets
+
+
+
+
e3 — shadow-pop
Hero card · CTAs
+
+
+
+
+
Primary button stack
+
The signature treatment. A 6 px solid color bar plus a tinted halo — compresses to 2 px on press.
+
+ Get started
+ Find a match
+ Add to cart
+ Schedule visit
+
+
+
+
+
+
+
+ 06
+
Spacing — 4 dp base.
+
+ Spacing follows a 4 dp base. Card padding is 16–22 dp. Bottom-nav lives 10 dp from the bottom safe area. Tap targets are never below 44 dp; primary affordances target 52 dp.
+
+
+
+
+
+
+
+ 07
+
Motion — bouncy, not springy.
+
+ Motion leans on a single keyframe family — `pop-in`, `float-up`, `tail-wag`, `wiggle`, `bounce-soft`, `confetti-fall`. Easings prefer overshoot for confirmation moments (toggle knob, milestone), simple ease-out for transitions.
+
+
+
duration/xs 80 ms Tap feedback · button compress
+
duration/sm 140 ms Hover · color tween
+
duration/md 220 ms Page slide · sheet present
+
duration/lg 320 ms Pop-in · milestone confirm
+
duration/xl 500 ms Loader cycles · reaction burst
+
easing/bounce cubic(.5, 1.7, .5, 1) Toggle knob · paw confirm
+
easing/standard cubic(.2, .8, .2, 1) Default sheet motion
+
+
+
+
+
+
+
+
+
+ 09
+
Cards — squircles with breath.
+
+ Cards are 28 px squircles with a 1 px warm hairline and the standard card shadow. Inside, content gets 18 dp of breathing room. Quest rows compress to a 22 px squircle and `e1`.
+
+
+
+
+
+
+
+
Morning meal
+
8:00 AM · Done
+
+
+10 ★
+
+
+
+
+
+
+
+
Heartworm pill
+
Due 12:00 PM
+
+
+20 ★
+
+
+
+
+
+
Tooth brush Weekly
+
Bedtime
+
+
+25 ★
+
+
+
+
+
+
+
+
+
+
+ 🔥 7-day
+ 💖 New match
+ ✓ Up to date
+ Weekly
+ +10 XP
+
+
+
+
+
+
+
+
+ 10
+
Avatars & species — radial gradients + emoji.
+
+ Pet avatars are perfect circles filled with a radial gradient from the species `--soft` tint into the species base color, topped by the species emoji. The active-pet avatar gets a conic-gradient ring made from the four primary accents.
+
+
+
+
+
+
+
+
+
+
+
+ 11
+
Controls — paws and bones.
+
+ Two custom controls give the system its toy-like feeling. The Paw toggle — a switch whose knob carries a paw icon. The Bone slider — a range whose thumb is a bone shape, tilted 15°.
+
+
+
+
Paw toggle
+
54 × 30 with a 24 px round knob. The paw inside flips from gray to accent on activation.
+
+
+
+
+
+
+
Notifications on
+
+
+
+
+
+
Quiet hours off
+
+
+
+
+
Bone slider
+
Track gradient tangerine → poppy; thumb is a 4-circle + bar bone shape.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Indoor only
+ 10 km
+
+
+
+
+
+
+
+
+ 12
+
Navigation — 5 tabs, pillar-tinted.
+
+ A floating bottom-nav lives 10 dp from the home indicator. The active tab's icon gets a translucent pill background in the pillar accent. Inactive tabs are 600-weight, active tabs 800-weight.
+
+
+
+
+ Pets
+
+
+
+ Care
+
+
+
+ Social
+
+
+
+ Match
+
+
+
+ Market
+
+
+ Each tab also carries a 3 px pillar-colored underline below the label when active — a quiet reminder of the current pillar.
+
+
+
+
+
+ 13
+
Iconography — stroke + filled.
+
+ All icons are drawn on a 24 px grid with 1.9 px stroke, round caps, round joins. Two weights: stroke for inactive states, filled for active states and selection. Pet-class icons are emoji (intentional — they carry color and warmth that bespoke vector cannot).
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
pill
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 14
+
Motifs — waves, paws, confetti.
+
+ Four recurring decorative elements give the system its personality across screens. None of them are required — they're invited in for moments that earn them.
+
+
+
+
+
+
+
Active pet
+ Mochi
is feeling cuddly
+
+
+
Used as the top of every pet-detail and pillar landing screen.
+
+
+
+
+
+
+
Used behind onboarding, empty states, and milestone celebrations. Mixed accent colors at 16–22% opacity.
+
+
+
+
+
+
+
+
🐾💖🦴⭐🐾
+
Milestone unlocked
+
Paws, hearts, treats, and stars float up from the tap point in a single 900 ms burst.
+
+
+
+
+
+
+ PETFOLIO · DESIGN SYSTEM · V1 · CREAM + SIX POPS
+
+
+
+
+
+
+
diff --git a/PetFolio Redesign/android-frame.jsx b/PetFolio Redesign/android-frame.jsx
new file mode 100644
index 0000000..55b977a
--- /dev/null
+++ b/PetFolio Redesign/android-frame.jsx
@@ -0,0 +1,214 @@
+
+// Android.jsx — Simplified Android (Material 3) device frame
+// Status bar + top app bar + content + gesture nav + keyboard.
+// Based on Figma M3 spec. No dependencies, no image assets.
+
+const MD_C = {
+ surface: '#f4fbf8',
+ surfaceVariant: '#dae5e1',
+ inverseOnSurface: '#ecf2ef',
+ secondaryContainer: '#cde8e1',
+ primaryFixedDim: '#83d5c6',
+ onSurface: '#171d1b',
+ onSurfaceVar: '#49454f',
+ onPrimaryContainer: '#00201c',
+ primary: '#006a60',
+ frameBorder: 'rgba(116,119,117,0.5)',
+};
+
+// ─────────────────────────────────────────────────────────────
+// Status bar (time left, wifi/cell/battery right)
+// ─────────────────────────────────────────────────────────────
+function AndroidStatusBar({ dark = false }) {
+ const c = dark ? '#fff' : MD_C.onSurface;
+ return (
+
+ {/* time left */}
+
+ 9:30
+
+ {/* camera punch-hole (center) */}
+
+ {/* status icons right */}
+
+
+ );
+}
+
+// ─────────────────────────────────────────────────────────────
+// Top app bar (Material 3 small/medium)
+// ─────────────────────────────────────────────────────────────
+function AndroidAppBar({ title = 'Title', large = false }) {
+ const iconDot = (
+
+ );
+ return (
+
+
+ {iconDot}
+ {!large && (
+
{title}
+ )}
+ {large &&
}
+ {iconDot}
+
+ {large && (
+
{title}
+ )}
+
+ );
+}
+
+// ─────────────────────────────────────────────────────────────
+// List item (Material 3)
+// ─────────────────────────────────────────────────────────────
+function AndroidListItem({ headline, supporting, leading }) {
+ return (
+
+ {leading && (
+
{leading}
+ )}
+
+
{headline}
+ {supporting && (
+
{supporting}
+ )}
+
+
+ );
+}
+
+// ─────────────────────────────────────────────────────────────
+// Gesture nav bar (pill)
+// ─────────────────────────────────────────────────────────────
+function AndroidNavBar({ dark = false }) {
+ return (
+
+ );
+}
+
+// ─────────────────────────────────────────────────────────────
+// Device frame — wraps everything
+// ─────────────────────────────────────────────────────────────
+function AndroidDevice({
+ children, width = 412, height = 892, dark = false,
+ title, large = false, keyboard = false,
+}) {
+ return (
+
+
+ {title !== undefined &&
}
+
+ {children}
+
+ {keyboard &&
}
+
+
+ );
+}
+
+// ─────────────────────────────────────────────────────────────
+// Keyboard — Gboard (Material 3)
+// ─────────────────────────────────────────────────────────────
+function AndroidKeyboard() {
+ let _k = 0;
+ const key = (l, { flex = 1, bg = MD_C.surface, r = 6, minW, fs = 21 } = {}) => (
+ {l}
+ );
+ const row = (keys, style = {}) => (
+
+ {keys.map(l => key(l))}
+
+ );
+ return (
+
+ {/* navbar spacer (icons omitted) */}
+
+ {/* key rows */}
+
+ {row(['q','w','e','r','t','y','u','i','o','p'])}
+ {row(['a','s','d','f','g','h','j','k','l'], { padding: '0 20px' })}
+
+ {key('', { bg: MD_C.surfaceVariant })}
+
+ {['z','x','c','v','b','n','m'].map(l => key(l))}
+
+ {key('', { bg: MD_C.surfaceVariant })}
+
+
+ {key('?123', { bg: MD_C.secondaryContainer, r: 100, minW: 58, fs: 14 })}
+ {key(',', { bg: MD_C.surfaceVariant })}
+ {key('', { flex: 3, minW: 154 })}
+ {key('.', { bg: MD_C.surfaceVariant })}
+ {key('', { bg: MD_C.primaryFixedDim, r: 100, minW: 58 })}
+
+
+
+ );
+}
+
+Object.assign(window, {
+ AndroidDevice, AndroidStatusBar, AndroidAppBar, AndroidListItem, AndroidNavBar, AndroidKeyboard,
+});
diff --git a/PetFolio Redesign/app.jsx b/PetFolio Redesign/app.jsx
new file mode 100644
index 0000000..8a61ef8
--- /dev/null
+++ b/PetFolio Redesign/app.jsx
@@ -0,0 +1,188 @@
+// app.jsx — Top-level PetFolio app: routing, tweaks, frame wrapper
+
+const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
+ "dark": false,
+ "primary": "#FF8A4C",
+ "palette": ["#FF8A4C","#FF3D3D","#2FCBA0","#FFC53D","#A98BFF"],
+ "showFrame": true,
+ "motif": "confident",
+ "startScreen": "home"
+}/*EDITMODE-END*/;
+
+const PALETTES = [
+ // [tangerine, bubblegum, mint, sunny, lilac]
+ { id: 'warm', label: 'Warm pop', colors: ['#FF8A4C','#FF3D3D','#2FCBA0','#FFC53D','#A98BFF'] },
+ { id: 'pastel', label: 'Soft pastel', colors: ['#FFA37A','#FF9090','#7EE4C5','#FFD771','#C9B3FF'] },
+ { id: 'bold', label: 'Bold Pop', colors: ['#FF3D3D','#FF8A4C','#5BD9F8','#FFD93C','#9C6BFF'] },
+ { id: 'meadow', label: 'Meadow', colors: ['#34C29B','#FFB347','#FF5050','#FFE066','#7BB7FF'] },
+];
+
+function applyPalette(p, dark) {
+ const root = document.documentElement;
+ const [tangerine, bubblegum, mint, sunny, lilac] = p;
+ root.style.setProperty('--tangerine', tangerine);
+ root.style.setProperty('--bubblegum', bubblegum);
+ root.style.setProperty('--mint', mint);
+ root.style.setProperty('--sunny', sunny);
+ root.style.setProperty('--lilac', lilac);
+ // Soft tints — mix toward white in light mode, toward dark surface in dark mode.
+ // Dark mode soft surfaces should still be dark so foreground cream text stays readable.
+ const softBase = dark ? '#1A1014' : 'white';
+ const softPct = dark ? 28 : 22;
+ function soft(hex) { return `color-mix(in oklab, ${hex} ${softPct}%, ${softBase})`; }
+ // -700 = darker accent for "on tint" text. In dark mode we want a LIGHTER accent
+ // (so colored text reads against dark soft surfaces).
+ function strong(hex) { return dark
+ ? `color-mix(in oklab, ${hex} 65%, white)`
+ : `color-mix(in oklab, ${hex} 60%, black)`; }
+ root.style.setProperty('--tangerine-soft', soft(tangerine));
+ root.style.setProperty('--bubblegum-soft', soft(bubblegum));
+ root.style.setProperty('--mint-soft', soft(mint));
+ root.style.setProperty('--sunny-soft', soft(sunny));
+ root.style.setProperty('--lilac-soft', soft(lilac));
+ root.style.setProperty('--tangerine-700', strong(tangerine));
+ root.style.setProperty('--bubblegum-700', strong(bubblegum));
+ root.style.setProperty('--mint-700', strong(mint));
+ root.style.setProperty('--sunny-700', strong(sunny));
+ root.style.setProperty('--lilac-700', strong(lilac));
+}
+
+function App() {
+ const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
+
+ // Apply theme/dark mode + repaint palette
+ React.useEffect(() => {
+ document.documentElement.setAttribute('data-theme', t.dark ? 'dark' : 'light');
+ if (Array.isArray(t.palette)) applyPalette(t.palette, !!t.dark);
+ }, [t.dark, t.palette]);
+
+ // App state
+ const [route, setRoute] = React.useState('onboarding'); // onboarding | home | care | social | match | market | health
+ const [activePet, setActivePet] = React.useState('mochi');
+ const [switcherOpen, setSwitcherOpen] = React.useState(false);
+
+ React.useEffect(() => {
+ // After mount, also accept startScreen tweak to jump directly
+ if (t.startScreen && t.startScreen !== 'onboarding') {
+ setRoute(t.startScreen);
+ }
+ }, []);
+
+ function navigate(screen) {
+ setRoute(screen);
+ }
+
+ const isTab = ['home','care','social','match','market'].includes(route);
+
+ return (
+
+
+ {t.showFrame ? (
+
+
+
+ ) : (
+
+ )}
+
+
+
+
+ setRoute(v)}/>
+
+
+ setTweak('dark', v)}/>
+ p.colors)}
+ onChange={v => setTweak('palette', v)}/>
+
+
+ setTweak('motif', v)}/>
+
+
+ setTweak('showFrame', v)}/>
+
+
+ );
+}
+
+function AppShell({ route, navigate, activePet, setActivePet, switcherOpen, setSwitcherOpen, motif, isTab, noStatus }) {
+ // Map screen labels for comment context
+ const screenLabel = {
+ onboarding: '01 Onboarding',
+ home: '02 Home',
+ care: '03 Care',
+ social: '04 Social',
+ match: '05 Match',
+ market: '06 Market',
+ health: '07 Health Vault',
+ }[route];
+
+ return (
+
+ {/* Screen */}
+ {route === 'onboarding' &&
navigate('home')}/>}
+ {route === 'home' && setSwitcherOpen(true)} navigate={navigate} motif={motif}/>}
+ {route === 'care' && setSwitcherOpen(true)} navigate={navigate} motif={motif}/>}
+ {route === 'social' && }
+ {route === 'match' && setSwitcherOpen(true)} activePet={activePet} motif={motif}/>}
+ {route === 'market' && }
+ {route === 'health' && }
+
+ {/* Bottom nav (only on tab screens) */}
+ {isTab && }
+
+ {/* Pet switcher sheet — overlays anywhere */}
+ setSwitcherOpen(false)}
+ active={activePet}
+ setActive={setActivePet}
+ navigate={navigate}
+ />
+
+ );
+}
+
+ReactDOM.createRoot(document.getElementById('root')).render( );
diff --git a/PetFolio Redesign/care.jsx b/PetFolio Redesign/care.jsx
new file mode 100644
index 0000000..348cb99
--- /dev/null
+++ b/PetFolio Redesign/care.jsx
@@ -0,0 +1,242 @@
+// care.jsx — Gamified daily care (Duolingo-style) + badge vault
+
+function CareScreen({ activePet, openSwitcher, navigate, motif }) {
+ const pet = DEMO_PETS.find(p => p.id === activePet) || DEMO_PETS[0];
+ const sp = SPECIES.find(s => s.id === pet.species) || SPECIES[0];
+ const [tasks, setTasks] = React.useState([
+ { id: 't1', icon: '🦴', label: 'Breakfast', time: '8:00 AM', xp: 10, done: true, color: 'var(--tangerine)' },
+ { id: 't2', icon: '💊', label: 'Heartworm pill', time: '12:00 PM', xp: 20, done: false, color: 'var(--poppy)', due: true },
+ { id: 't3', icon: '🚶', label: 'Evening walk', time: '5:30 PM', xp: 15, done: false, color: 'var(--mint)' },
+ { id: 't4', icon: '🍖', label: 'Dinner', time: '6:30 PM', xp: 10, done: false, color: 'var(--sunny)' },
+ { id: 't5', icon: '🪥', label: 'Tooth brush', time: 'Bedtime', xp: 25, done: false, color: 'var(--lilac)', weekly: true },
+ ]);
+ const [burst, setBurst] = React.useState(null);
+
+ const completed = tasks.filter(t => t.done).length;
+ const totalXP = tasks.filter(t => t.done).reduce((s, t) => s + t.xp, 0);
+ const progress = (completed / tasks.length) * 100;
+
+ function toggle(id, e) {
+ setTasks(ts => ts.map(t => t.id === id ? { ...t, done: !t.done } : t));
+ if (e) {
+ const t = tasks.find(x => x.id === id);
+ if (t && !t.done) {
+ const rect = e.currentTarget.getBoundingClientRect();
+ const parentRect = e.currentTarget.offsetParent?.getBoundingClientRect();
+ setBurst({
+ x: rect.left - (parentRect?.left||0) + rect.width/2,
+ y: rect.top - (parentRect?.top||0) + rect.height/2,
+ xp: t.xp,
+ });
+ setTimeout(() => setBurst(null), 1200);
+ }
+ }
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
CARING FOR
+
{pet.name} {I.chevronDown(14)}
+
+
+
navigate('health')} bg="var(--mint-soft)" color="var(--mint-700)" shadow={false}>{I.stethoscope(22)}
+
+
+ {/* Big streak + XP ring */}
+
+ {/* Streak flame */}
+
+
+
+
🔥
+
{pet.streak}
+
DAY STREAK
+
+
+ {/* Pulse ring */}
+
+
+
+ {/* XP & level */}
+
+
+ Lv 7
+ Caretaker
+
+
{pet.xp + totalXP} / 600 XP
+
+
118 XP to Lv 8 · Pet Whisperer
+
+
+
+
+ {/* Today's quests */}
+
+ {completed}/{tasks.length} done
+ }>Today's quests
+
+
+ {tasks.map(t => (
+ toggle(t.id, e)}/>
+ ))}
+
+
+ {/* XP burst */}
+ {burst && (
+
+ )}
+
+
+ {/* Weekly chart */}
+
+
This week
+
+
+ {[
+ { d: 'M', h: 86, c: 'var(--tangerine)' },
+ { d: 'T', h: 94, c: 'var(--poppy)' },
+ { d: 'W', h: 70, c: 'var(--mint)' },
+ { d: 'T', h: 100, c: 'var(--sunny)' },
+ { d: 'F', h: 88, c: 'var(--lilac)' },
+ { d: 'S', h: 60, c: 'var(--tangerine)' },
+ { d: 'S', h: Math.max(20, progress), c: 'var(--poppy)', today: true },
+ ].map((b, i) => (
+
+ ))}
+
+
+
+
+ {/* Badge vault */}
+
+
Vault →}>
+ Trophy room
+
+
+ {[
+ { e: '🔥', c: 'var(--sunny)', l: '7-Day', own: true },
+ { e: '💯', c: 'var(--poppy)', l: '100 XP', own: true },
+ { e: '🦴', c: 'var(--tangerine)', l: 'Treat Pro', own: true },
+ { e: '💉', c: 'var(--mint)', l: 'Vaccinated',own: true },
+ { e: '🎓', c: 'var(--lilac)', l: 'Trained', own: false },
+ { e: '🏆', c: 'var(--sunny)', l: '30-Day', own: false },
+ { e: '🌟', c: 'var(--poppy)', l: 'Lv 10', own: false },
+ { e: '👑', c: 'var(--tangerine)', l: 'Top 1%', own: false },
+ ].map((b, i) => (
+
+ ))}
+
+
+
+ );
+}
+
+function TaskRow({ task, onToggle }) {
+ return (
+
+
{task.done ? '✅' : task.icon}
+
+
+ {task.label}
+ {task.weekly && WEEKLY }
+
+
{task.due ? `Due ${task.time}` : task.time}
+
+
+
+{task.xp} XP
+
+ {task.done && I.check(18, '#fff')}
+
+
+
+ );
+}
+
+function BadgeTile({ e, c, l, own }) {
+ return (
+
+ );
+}
+
+Object.assign(window, { CareScreen });
diff --git a/PetFolio Redesign/components.jsx b/PetFolio Redesign/components.jsx
new file mode 100644
index 0000000..d0d8f93
--- /dev/null
+++ b/PetFolio Redesign/components.jsx
@@ -0,0 +1,436 @@
+// components.jsx — Shared building blocks for PetFolio
+
+// ─── Iconography (stroke + filled) ─────────────────────────
+const I = {
+ paw: (size = 22, fill = 'currentColor') => (
+
+
+
+
+
+
+
+ ),
+ pawOutline: (size = 22, color = 'currentColor') => (
+
+
+
+
+
+
+
+ ),
+ heart: (size = 22, fill = 'currentColor') => (
+
+ ),
+ heartOutline: (size = 22, color = 'currentColor') => (
+
+ ),
+ bone: (size = 22, color = 'currentColor') => (
+
+
+
+ ),
+ flame: (size = 22) => (
+
+
+
+
+ ),
+ star: (size = 22, fill = '#FFC53D') => (
+
+ ),
+ trophy: (size = 22) => (
+
+
+
+
+
+ ),
+ bell: (s=22,c='currentColor') => ,
+ back: (s=22,c='currentColor') => ,
+ chevron: (s=22,c='currentColor') => ,
+ chevronDown: (s=22,c='currentColor') => ,
+ close: (s=22,c='currentColor') => ,
+ plus: (s=22,c='currentColor') => ,
+ check: (s=22,c='currentColor') => ,
+ comment: (s=22,c='currentColor') => ,
+ share: (s=22,c='currentColor') => ,
+ bookmark: (s=22,c='currentColor') => ,
+ search: (s=22,c='currentColor') => ,
+ filter: (s=22,c='currentColor') => ,
+ cart: (s=22,c='currentColor') => ,
+ syringe: (s=22) => ,
+ pill: (s=22) => ,
+ stethoscope: (s=22) => ,
+ send: (s=22,c='currentColor') => ,
+ spark: (s=18) => ,
+ location: (s=22,c='currentColor') => ,
+ settings: (s=22,c='currentColor') => ,
+};
+
+// ─── Species (the 6 supported pet classes) ─────────────────
+const SPECIES = [
+ { id: 'dog', label: 'Dog', emoji: '🐶', color: 'var(--tangerine)', soft: 'var(--tangerine-soft)' },
+ { id: 'cat', label: 'Cat', emoji: '🐱', color: 'var(--poppy)', soft: 'var(--poppy-soft)' },
+ { id: 'rabbit', label: 'Rabbit', emoji: '🐰', color: 'var(--lilac)', soft: 'var(--lilac-soft)' },
+ { id: 'bird', label: 'Bird', emoji: '🐦', color: 'var(--sky)', soft: 'var(--sky-soft)' },
+ { id: 'fish', label: 'Fish', emoji: '🐠', color: 'var(--mint)', soft: 'var(--mint-soft)' },
+ { id: 'reptile', label: 'Reptile', emoji: '🦎', color: 'var(--sunny)', soft: 'var(--sunny-soft)' },
+];
+
+// ─── Pet avatar — gradient disc + emoji, optional ring ─────
+function PetAvatar({ size = 48, species = 'cat', ring = false, glow = false, style }) {
+ const sp = SPECIES.find(s => s.id === species) || SPECIES[0];
+ const inner = size - (ring ? 6 : 0);
+ return (
+
+ );
+}
+
+// ─── Pill button (primary / secondary / ghost / soft) ───────
+function Pill({ children, variant = 'primary', onClick, disabled, size = 'md', icon, iconRight, full, style, color }) {
+ const heights = { sm: 36, md: 48, lg: 56, xl: 64 };
+ const pads = { sm: 14, md: 22, lg: 26, xl: 30 };
+ const fs = { sm: 14, md: 16, lg: 17, xl: 18 };
+ const palette = {
+ primary: { bg: color || 'var(--tangerine)', fg: '#fff', sh: '0 6px 0 0 var(--tangerine-700), 0 14px 24px -10px rgba(255,138,76,0.6)' },
+ soft: { bg: 'var(--tangerine-soft)', fg: 'var(--tangerine-700)', sh: 'none' },
+ ghost: { bg: 'transparent', fg: 'var(--ink-950)', sh: 'none' },
+ outline: { bg: 'var(--surface)', fg: 'var(--ink-950)', sh: 'inset 0 0 0 2px var(--line-2)' },
+ dark: { bg: 'var(--ink-950)', fg: 'var(--cream)', sh: '0 6px 0 0 #000, 0 12px 24px -10px rgba(0,0,0,0.4)' },
+ };
+ const p = palette[variant] || palette.primary;
+ return (
+ { e.currentTarget.style.transform = 'translateY(2px)'; e.currentTarget.style.boxShadow = p.sh ? p.sh.replace('6px', '2px') : 'none'; }}
+ onPointerUp={e => { e.currentTarget.style.transform = ''; e.currentTarget.style.boxShadow = p.sh; }}
+ onPointerLeave={e => { e.currentTarget.style.transform = ''; e.currentTarget.style.boxShadow = p.sh; }}
+ >
+ {icon}
+ {children}
+ {iconRight}
+
+ );
+}
+
+// ─── Round icon button ─────────────────────────────────────
+function IconBtn({ children, onClick, size = 44, bg = 'var(--surface)', color = 'var(--ink-950)', shadow = true, style }) {
+ return (
+ {children}
+ );
+}
+
+// ─── Wave header — organic top SVG ─────────────────────────
+function WaveHeader({ color = 'var(--tangerine)', height = 140, children, style }) {
+ return (
+
+ );
+}
+
+// ─── Squircle card ─────────────────────────────────────────
+function Card({ children, style, onClick, color = 'var(--surface)', pad = 18, r = 28 }) {
+ return (
+ {children}
+ );
+}
+
+// ─── Paw toggle (custom switch shaped like a paw track) ────
+function PawToggle({ checked, onChange, color = 'var(--tangerine)' }) {
+ return (
+ onChange(!checked)} style={{
+ width: 54, height: 30, borderRadius: 999, border: 'none', cursor: 'pointer',
+ background: checked ? color : 'var(--line-2)',
+ position: 'relative', padding: 3,
+ transition: 'background 200ms ease',
+ boxShadow: 'inset 0 1px 3px rgba(0,0,0,0.08)',
+ }}>
+
+ {I.paw(14, checked ? color : 'var(--ink-300)')}
+
+
+ );
+}
+
+// ─── Bone slider ───────────────────────────────────────────
+function BoneSlider({ value = 50, onChange, min = 0, max = 100, color = 'var(--tangerine)' }) {
+ const pct = ((value - min) / (max - min)) * 100;
+ return (
+
+
+
+ {/* Bone-shaped thumb */}
+
+
+
+
+
+
+
+
+
+
+
onChange(+e.target.value)} style={{
+ position: 'absolute', inset: 0, opacity: 0, cursor: 'pointer', width: '100%',
+ }}/>
+
+ );
+}
+
+// ─── Tail-wag loader ───────────────────────────────────────
+function TailWagLoader({ size = 70, label }) {
+ return (
+
+
+ {/* Body */}
+
+ {/* Head */}
+
+ {/* Ear */}
+
+ {/* Eye */}
+
+ {/* Tail (wagging) */}
+
+
+ {label &&
{label}
}
+
+ );
+}
+
+// ─── Reaction burst (paws/hearts/treats flying up) ─────────
+function ReactionBurst({ items, kind = 'paw' }) {
+ if (!items || items.length === 0) return null;
+ return (
+
+ {items.map(it => {
+ const dx = (Math.random() - 0.5) * 60;
+ const rot = (Math.random() - 0.5) * 60;
+ const delay = Math.random() * 80;
+ const k = it.kind || kind;
+ const glyph = k === 'heart' ? '❤️' : k === 'treat' ? '🦴' : k === 'star' ? '⭐' : '🐾';
+ return (
+
{glyph}
+ );
+ })}
+
+ );
+}
+
+// ─── Bottom nav (5 tabs) ───────────────────────────────────
+function BottomNav({ active, onChange, motif = 'confident' }) {
+ const tabs = [
+ { id: 'home', label: 'Pets', icon: I.pawOutline, iconActive: I.paw, color: 'var(--tangerine)' },
+ { id: 'care', label: 'Care', icon: (s,c)=>I.flame(s), iconActive: s=>I.flame(s), color: 'var(--sunny-700)' },
+ { id: 'social', label: 'Social', icon: I.heartOutline, iconActive: I.heart, color: 'var(--poppy)' },
+ { id: 'match', label: 'Match', icon: (s,c)=>I.bone(s,c), iconActive: s=>I.bone(s,'var(--lilac)'), color: 'var(--lilac)' },
+ { id: 'market', label: 'Market', icon: I.cart, iconActive: (s,c)=>I.cart(s,'var(--mint-700)'), color: 'var(--mint-700)' },
+ ];
+ return (
+
+ {tabs.map(t => {
+ const isActive = active === t.id;
+ return (
+
onChange(t.id)} style={{
+ flex: 1, height: 56, border: 'none', background: 'transparent', cursor: 'pointer',
+ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
+ gap: 2, position: 'relative',
+ color: isActive ? t.color : 'var(--ink-500)',
+ }}>
+
+ {isActive
+ ? (typeof t.iconActive === 'function' ? t.iconActive(22, t.color) : t.iconActive)
+ : (typeof t.icon === 'function' ? t.icon(22, 'currentColor') : t.icon)}
+
+ {t.label}
+
+ );
+ })}
+
+ );
+}
+
+// ─── Wave divider (used inside cards / between sections) ───
+function WaveDivider({ color = 'var(--tangerine-soft)', flip = false, height = 24 }) {
+ return (
+
+
+
+ );
+}
+
+// ─── Section header (in-app, with motif accent) ────────────
+function SectionTitle({ children, accent = 'var(--tangerine)', right }) {
+ return (
+
+ );
+}
+
+// ─── Placeholder image (gradient + monospace label) ─────────
+function PlaceholderImg({ label = 'photo', color = 'var(--tangerine)', soft = 'var(--tangerine-soft)', height = 180, r = 24, style, emoji }) {
+ return (
+
+ {emoji &&
{emoji}
}
+ {!emoji && (
+
{label}
+ )}
+ {/* subtle paw watermark */}
+
+ {I.paw(28, '#fff')}
+
+
+ );
+}
+
+// ─── Confetti element (for celebrations) ───────────────────
+function Confetti({ count = 50, colors }) {
+ const cs = colors || ['var(--tangerine)','var(--poppy)','var(--mint)','var(--sunny)','var(--lilac)'];
+ const items = Array.from({ length: count }).map((_, i) => {
+ const x = Math.random() * 100;
+ const dx = (Math.random() - 0.5) * 60;
+ const rot = Math.random() * 720 + 360;
+ const delay = Math.random() * 600;
+ const duration = 1800 + Math.random() * 1200;
+ const c = cs[i % cs.length];
+ const w = 8 + Math.random() * 6;
+ const h = 4 + Math.random() * 6;
+ return (
+
+ );
+ });
+ return {items}
;
+}
+
+// Export to window
+Object.assign(window, {
+ I, SPECIES, PetAvatar, Pill, IconBtn, WaveHeader, Card, PawToggle, BoneSlider,
+ TailWagLoader, ReactionBurst, BottomNav, WaveDivider, SectionTitle, PlaceholderImg, Confetti,
+});
diff --git a/PetFolio Redesign/health.jsx b/PetFolio Redesign/health.jsx
new file mode 100644
index 0000000..dccbf86
--- /dev/null
+++ b/PetFolio Redesign/health.jsx
@@ -0,0 +1,159 @@
+// health.jsx — Medical vault (warm but trustworthy)
+
+function HealthScreen({ activePet, navigate, motif }) {
+ const pet = DEMO_PETS.find(p => p.id === activePet) || DEMO_PETS[0];
+
+ return (
+
+ {/* Header with mint pillar tint */}
+
+
+
navigate('care')} size={40}>{I.back(22)}
+
+ {pet.name.toUpperCase()} · MEDICAL VAULT
+
+
{I.plus(22, '#fff')}
+
+
+
+ Everything healthy , in one cozy spot.
+
+
+ Vaccines, meds, and vet visits — synced live from {pet.name}'s clinic.
+
+
+ {/* Health pulse summary */}
+
+
+
+
+
+
+
+ {/* Body */}
+
+ {/* Vaccines */}
+
+
+
+
+
+
+ {/* Medications */}
+
+
+
+
+
+ {/* Vet visits */}
+
+
+
+
+
+ {/* Share with vet card */}
+
+
+
🩺
+
+
Share with your vet
+
Generate a temporary QR — expires in 24h
+
+
Share
+
+
+
+
+ );
+}
+
+function HealthPill({ icon, label, value, tone }) {
+ return (
+
+
{icon}
+
{label}
+
{value}
+
+ );
+}
+
+function VaultSection({ title, accent, icon, count, children }) {
+ return (
+
+
+
{icon}
+
{title}
+
{count}
+
+
+ {children}
+
+
+ );
+}
+
+function RecordRow({ color, icon, title, date, tag, status, note }) {
+ return (
+
+
{icon}
+
+
+ {title}
+ {tag && {tag} }
+
+
{date}
+ {note &&
{note}
}
+
+
{status.label}
+
+ );
+}
+
+Object.assign(window, { HealthScreen });
diff --git a/PetFolio Redesign/home.jsx b/PetFolio Redesign/home.jsx
new file mode 100644
index 0000000..a0a895a
--- /dev/null
+++ b/PetFolio Redesign/home.jsx
@@ -0,0 +1,266 @@
+// home.jsx — Home dashboard (Pets tab) + Pet switcher sheet
+
+const DEMO_PETS = [
+ { id: 'mochi', name: 'Mochi', species: 'cat', breed: 'Persian', age: '3y', xp: 482, streak: 7, color: 'var(--poppy)' },
+ { id: 'tommy', name: 'Tommy', species: 'dog', breed: 'Golden Retriever', age: '5y', xp: 1240, streak: 12, color: 'var(--tangerine)' },
+ { id: 'goldy', name: 'Goldy', species: 'fish', breed: 'Goldfish', age: '1y', xp: 88, streak: 2, color: 'var(--mint)' },
+ { id: 'nori', name: 'Nori', species: 'fish', breed: 'Betta', age: '8m', xp: 42, streak: 0, color: 'var(--mint)' },
+ { id: 'rex', name: 'Rex', species: 'reptile', breed: 'Bearded Dragon', age: '2y', xp: 220, streak: 3, color: 'var(--sunny)' },
+];
+
+function HomeScreen({ activePet, setActivePet, openPetSwitcher, navigate, motif }) {
+ const pet = DEMO_PETS.find(p => p.id === activePet) || DEMO_PETS[0];
+ const sp = SPECIES.find(s => s.id === pet.species) || SPECIES[0];
+
+ return (
+
+ {/* Wave header with pet hero */}
+
+ {/* Status row */}
+
+
+
+
+
ACTIVE PET
+
+ {pet.name} {I.chevronDown(14, '#fff')}
+
+
+
+
+ {I.bell(20, '#fff')}
+ navigate('settings')}>{I.settings(20, '#fff')}
+
+
+
+ {/* Big hero greeting */}
+
+
Good morning, mama 💛
+
+ {pet.name} is feelingcuddly today.
+
+
+
+ {/* Pet card peeking on header */}
+
+
+
+
+
{pet.name}
+
{pet.breed} · {pet.age}
+
+
+ {I.flame(16)} {pet.streak}
+
+
+
+
+ {/* Wave bottom */}
+
+
+
+
+
+ {/* Body */}
+
+ {/* Quick stats trio */}
+
+
+
+
+
+
+ {/* Today's care preview */}
+
navigate('care')} style={{ background: 'transparent', border: 'none', color: 'var(--tangerine-700)', fontWeight: 800, fontSize: 13, cursor: 'pointer' }}>See all →
+ }>Today's quests
+
+
+
+
+
+
+
+
+
+ {/* Recent moments */}
+
Gallery →}>
+ Recent moments
+
+
+
+ {/* Recent badges */}
+
Recent achievements
+
+ {[
+ { color: 'var(--sunny)', emoji: '🔥', label: '7-Day Hero' },
+ { color: 'var(--mint)', emoji: '🏥', label: 'Vet Visit' },
+ { color: 'var(--poppy)', emoji: '💖', label: '100 Likes' },
+ { color: 'var(--lilac)', emoji: '🎓', label: 'Trained' },
+ { color: 'var(--tangerine)', emoji: '🦴', label: 'Treat Master' },
+ ].map((b, i) => (
+
+
{b.emoji}
+
{b.label}
+
+ ))}
+
+
+
+ );
+}
+
+function StatTile({ icon, value, label, color, textColor }) {
+ return (
+
+
{icon}
+
{value}
+
{label}
+
+ );
+}
+
+function DailyQuestRow({ icon, label, time, xp, done, due }) {
+ return (
+
+
{done ? '✅' : icon}
+
+
{label}
+
+ {due ? `Due ${time}` : time}
+
+
+
+{xp} {I.star(12, done ? 'var(--mint-700)' : 'var(--sunny-700)')}
+
+ );
+}
+
+function Divider() {
+ return
;
+}
+
+// ─── Pet Switcher (bottom sheet) ────────────────────────────
+function PetSwitcher({ open, onClose, active, setActive, navigate }) {
+ const [phase, setPhase] = React.useState('closed'); // closed / opening / open / closing
+ React.useEffect(() => {
+ if (open) {
+ setPhase('opening');
+ requestAnimationFrame(() => setPhase('open'));
+ } else if (phase !== 'closed') {
+ setPhase('closing');
+ setTimeout(() => setPhase('closed'), 250);
+ }
+ }, [open]);
+
+ if (phase === 'closed') return null;
+ const visible = phase === 'open';
+
+ return (
+
+ {/* Scrim */}
+
+ {/* Sheet */}
+
+
+
+
+
Your pack
+
{DEMO_PETS.length} pets · tap to switch
+
+
{I.close(18)}
+
+
+
+ {DEMO_PETS.map(p => {
+ const isActive = p.id === active;
+ const sp = SPECIES.find(s => s.id === p.species) || SPECIES[0];
+ return (
+
{ setActive(p.id); onClose(); }} style={{
+ background: isActive ? sp.soft : 'var(--surface)',
+ borderRadius: 22, border: `2px solid ${isActive ? sp.color : 'var(--line)'}`,
+ padding: '12px 14px', cursor: 'pointer',
+ display: 'flex', alignItems: 'center', gap: 14,
+ boxShadow: 'var(--shadow-soft)',
+ }}>
+
+
+
{p.name}
+
{p.breed} · {p.age}
+
+
+ {I.flame(14)}
+ {p.streak}
+
+ {isActive && {I.check(16, '#fff')}
}
+
+ );
+ })}
+
+
+
+
+ {I.plus(22, '#fff')}
+
+
+
Add another pet
+
Name, breed, photo — 30 seconds
+
+
+
+
+ );
+}
+
+Object.assign(window, { HomeScreen, PetSwitcher, DEMO_PETS });
diff --git a/PetFolio Redesign/index.html b/PetFolio Redesign/index.html
new file mode 100644
index 0000000..79f355f
--- /dev/null
+++ b/PetFolio Redesign/index.html
@@ -0,0 +1,211 @@
+
+
+
+
+PetFolio — Redesign
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/PetFolio Redesign/market.jsx b/PetFolio Redesign/market.jsx
new file mode 100644
index 0000000..899290a
--- /dev/null
+++ b/PetFolio Redesign/market.jsx
@@ -0,0 +1,463 @@
+// market.jsx — Marketplace + Product detail + Cart drawer
+
+const DEMO_PRODUCTS = [
+ { id: 'pr1', name: 'Salmon Crunchies', sub: 'Treat · 200g', price: 12.50, vendor: 'WoofKitchen', emoji: '🦴', color: 'var(--tangerine)', soft: 'var(--tangerine-soft)', rating: 4.9 },
+ { id: 'pr2', name: 'Floofy Bed', sub: 'Medium', price: 48.00, vendor: 'CloudPet', emoji: '🛏️', color: 'var(--poppy)', soft: 'var(--poppy-soft)', rating: 4.8 },
+ { id: 'pr3', name: 'Bouncy Ball', sub: '6-pack', price: 7.20, vendor: 'PawPlay', emoji: '🎾', color: 'var(--mint)', soft: 'var(--mint-soft)', rating: 4.7 },
+ { id: 'pr4', name: 'Catnip Mouse', sub: 'Set of 3', price: 9.90, vendor: 'PurrLab', emoji: '🐭', color: 'var(--lilac)', soft: 'var(--lilac-soft)', rating: 4.6 },
+ { id: 'pr5', name: 'Wet Food Pack', sub: '12 × 85g', price: 24.00, vendor: 'WoofKitchen', emoji: '🥫', color: 'var(--sunny)', soft: 'var(--sunny-soft)', rating: 4.9 },
+ { id: 'pr6', name: 'Cozy Sweater', sub: 'Sz S', price: 18.00, vendor: 'YarnPaws', emoji: '🧶', color: 'var(--sky)', soft: 'var(--sky-soft)', rating: 4.8 },
+];
+
+const CATEGORIES = [
+ { id: 'food', label: 'Food', emoji: '🍖', color: 'var(--tangerine)' },
+ { id: 'treats',label: 'Treats', emoji: '🦴', color: 'var(--sunny)' },
+ { id: 'toys', label: 'Toys', emoji: '🎾', color: 'var(--mint)' },
+ { id: 'beds', label: 'Beds', emoji: '🛏️', color: 'var(--poppy)' },
+ { id: 'apparel',label: 'Apparel', emoji: '🧶', color: 'var(--lilac)' },
+ { id: 'care', label: 'Grooming', emoji: '🛁', color: 'var(--sky)' },
+];
+
+function MarketScreen({ navigate, motif }) {
+ const [view, setView] = React.useState({ kind: 'home' });
+ const [cart, setCart] = React.useState([
+ { id: 'pr3', qty: 1 },
+ ]);
+ const [drawer, setDrawer] = React.useState(false);
+ const [flying, setFlying] = React.useState(null);
+
+ function addToCart(product, fromRect) {
+ const existing = cart.find(c => c.id === product.id);
+ if (existing) {
+ setCart(c => c.map(x => x.id === product.id ? { ...x, qty: x.qty + 1 } : x));
+ } else {
+ setCart(c => [...c, { id: product.id, qty: 1 }]);
+ }
+ if (fromRect) {
+ setFlying({ rect: fromRect, product, id: Date.now() });
+ setTimeout(() => setFlying(null), 850);
+ }
+ }
+
+ const cartCount = cart.reduce((s, c) => s + c.qty, 0);
+ const cartItems = cart.map(c => ({ ...DEMO_PRODUCTS.find(p => p.id === c.id), qty: c.qty }));
+ const subtotal = cartItems.reduce((s, c) => s + c.price * c.qty, 0);
+
+ return (
+
+ {view.kind === 'home' &&
setDrawer(true)}
+ cartCount={cartCount}
+ onProduct={(p) => setView({ kind: 'product', product: p })}
+ onAdd={addToCart}
+ />}
+ {view.kind === 'product' && setView({ kind: 'home' })}
+ onCart={() => setDrawer(true)}
+ onAdd={addToCart}
+ cartCount={cartCount}
+ />}
+
+ {/* Cart drawer */}
+ setDrawer(false)}
+ items={cartItems}
+ subtotal={subtotal}
+ onQty={(id, q) => setCart(c => q === 0 ? c.filter(x => x.id !== id) : c.map(x => x.id === id ? { ...x, qty: q } : x))}
+ />
+
+ {/* Flying-to-cart pellet */}
+ {flying && }
+
+ );
+}
+
+function FlyToCart({ from, product }) {
+ // animate from rect to top-right cart icon (approx top 22, right 22)
+ return (
+
+ );
+}
+
+// ─── Marketplace Home ──────────────────────────────────────
+function MarketHome({ navigate, onCart, cartCount, onProduct, onAdd }) {
+ return (
+
+ {/* Header */}
+
+
+
+
SHIP TO MOCHI'S HOUSE
+
+ {I.location(16, 'var(--tangerine)')} Brooklyn, NY {I.chevronDown(14)}
+
+
+
+ {I.cart(20, '#fff')}
+ {cartCount > 0 && (
+ {cartCount}
+ )}
+
+
+
+ {/* Search */}
+
+ {I.search(20, 'var(--ink-500)')}
+
+
+
+
+ {/* Categories */}
+
+
+ {CATEGORIES.map(c => (
+
+
{c.emoji}
+
{c.label}
+
+ ))}
+
+
+
+ {/* Hero banner */}
+
+
+
+
FOR MEMBERS
+
20% off treats this week 🦴
+
Claim
+
+
🦴
+
🐾
+
+
+
+ {/* Trending */}
+
+
40+ items}>
+ Trending in your pack
+
+
+
+ {DEMO_PRODUCTS.map(p => (
+
onProduct(p)} onAdd={onAdd}/>
+ ))}
+
+
+
+ );
+}
+
+function ProductTile({ product, onTap, onAdd }) {
+ const btnRef = React.useRef(null);
+ const [popping, setPopping] = React.useState(false);
+
+ function handleAdd(e) {
+ e.stopPropagation();
+ const rect = btnRef.current?.getBoundingClientRect();
+ const parentRect = btnRef.current?.offsetParent?.getBoundingClientRect();
+ if (rect && parentRect) {
+ onAdd(product, { x: rect.left - parentRect.left + rect.width/2, y: rect.top - parentRect.top + rect.height/2 });
+ }
+ setPopping(true);
+ setTimeout(() => setPopping(false), 400);
+ }
+
+ return (
+
+
+
{product.emoji}
+
{I.star(12)} {product.rating}
+
+
+
{product.name}
+
{product.sub}
+
+ ${product.price.toFixed(2)}
+ {I.plus(20, '#fff')}
+
+
+
+ );
+}
+
+// ─── Product Detail ────────────────────────────────────────
+function ProductDetail({ product, onBack, onCart, onAdd, cartCount }) {
+ const [qty, setQty] = React.useState(1);
+ const [popping, setPopping] = React.useState(false);
+ const heroRef = React.useRef(null);
+
+ function handleAdd() {
+ const rect = heroRef.current?.getBoundingClientRect();
+ const parentRect = heroRef.current?.offsetParent?.getBoundingClientRect();
+ for (let i = 0; i < qty; i++) {
+ setTimeout(() => onAdd(product, rect && parentRect ? { x: rect.left - parentRect.left + rect.width/2, y: rect.top - parentRect.top + 70 } : null), i * 90);
+ }
+ setPopping(true);
+ setTimeout(() => setPopping(false), 600);
+ }
+
+ return (
+
+ {/* Big photo */}
+
+
+
{I.back(22)}
+
+ {I.bookmark(20)}
+
+ {I.cart(20)}
+ {cartCount > 0 && {cartCount} }
+
+
+
+
{product.emoji}
+
+ {/* Wave bottom */}
+
+
+
+
+
+ {/* Info */}
+
+
+ {I.star(12)} {product.rating} · 421 reviews
+ Free delivery
+
+
{product.name}
+
by {product.vendor} · {product.sub}
+
+
+ Crispy little bites baked with fresh salmon and a dusting of parsley. Single-protein, grain-free, and the very official taste-tester (Mochi) gives a 9.6/10.
+
+
+ {/* Highlights */}
+
+ {[
+ { e: '🐟', l: 'Single protein' },
+ { e: '🌾', l: 'Grain free' },
+ { e: '🇺🇸', l: 'Made in USA' },
+ { e: '🩺', l: 'Vet approved' },
+ ].map((h, i) => (
+
+ {h.e}
+ {h.l}
+
+ ))}
+
+
+ {/* Qty + add */}
+
+
+ setQty(q => Math.max(1, q - 1))} style={{ width: 36, height: 36, borderRadius: '50%', border: 'none', background: 'var(--cream-2)', cursor: 'pointer', fontSize: 18, fontWeight: 900 }}>−
+ {qty}
+ setQty(q => q + 1)} style={{ width: 36, height: 36, borderRadius: '50%', border: 'none', background: 'var(--cream-2)', cursor: 'pointer', fontSize: 18, fontWeight: 900 }}>+
+
+
${(product.price * qty).toFixed(2)}}>
+ Add to cart
+
+
+
+
+ );
+}
+
+// ─── Cart Drawer ───────────────────────────────────────────
+function CartDrawer({ open, onClose, items, subtotal, onQty }) {
+ const [phase, setPhase] = React.useState('closed');
+ React.useEffect(() => {
+ if (open) { setPhase('opening'); requestAnimationFrame(() => setPhase('open')); }
+ else if (phase !== 'closed') { setPhase('closing'); setTimeout(() => setPhase('closed'), 280); }
+ }, [open]);
+ if (phase === 'closed') return null;
+ const visible = phase === 'open';
+ const shipping = items.length > 0 ? 4.50 : 0;
+ const total = subtotal + shipping;
+
+ return (
+
+
+
+
+
+
+
Your basket
+
{items.length} item{items.length === 1 ? '' : 's'} · ships to Brooklyn
+
+
{I.close(18)}
+
+
+ {/* Items list */}
+
+ {items.length === 0 && (
+
+
🛒
+
Cart is empty
+
Tap a paw + to add treats
+
+ )}
+ {items.map(it => (
+
+
{it.emoji}
+
+
{it.name}
+
{it.sub}
+
${(it.price * it.qty).toFixed(2)}
+
+
+ onQty(it.id, it.qty - 1)} style={{ width: 26, height: 26, borderRadius: '50%', border: 'none', background: 'var(--surface)', cursor: 'pointer', fontWeight: 900 }}>−
+ {it.qty}
+ onQty(it.id, it.qty + 1)} style={{ width: 26, height: 26, borderRadius: '50%', border: 'none', background: 'var(--surface)', cursor: 'pointer', fontWeight: 900 }}>+
+
+
+ ))}
+
+ {/* Suggested add-on */}
+ {items.length > 0 && (
+
+
🦴
+
+
Add a treat for $4 more
+
Unlock free shipping
+
+
Add
+
+ )}
+
+
+ {/* Summary + checkout */}
+ {items.length > 0 && (
+
+
+
+
+
+
+
Checkout · ${total.toFixed(2)}
+
+ 🐾 Earn +{Math.floor(total * 4)} XP when you check out
+
+
+ )}
+
+
+ );
+}
+
+function Row({ label, value, big }) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
+
+Object.assign(window, { MarketScreen });
diff --git a/PetFolio Redesign/match.jsx b/PetFolio Redesign/match.jsx
new file mode 100644
index 0000000..24aff1d
--- /dev/null
+++ b/PetFolio Redesign/match.jsx
@@ -0,0 +1,318 @@
+// match.jsx — Pet Dating swipe stack + Mutual Match overlay
+
+const DEMO_MATCHES = [
+ { id: 'm1', name: 'Biscuit', age: 3, breed: 'Corgi', species: 'dog', dist: '0.4 km', bio: "Snuffle pro. Will trade belly rubs for treats.", color: 'var(--tangerine)', soft: 'var(--tangerine-soft)' },
+ { id: 'm2', name: 'Pepper', age: 2, breed: 'Tabby', species: 'cat', dist: '0.8 km', bio: 'Window watcher. Aspiring hat model.', color: 'var(--poppy)', soft: 'var(--poppy-soft)' },
+ { id: 'm3', name: 'Beans', age: 5, breed: 'Husky', species: 'dog', dist: '1.2 km', bio: 'Snow enthusiast. Dramatic singer.', color: 'var(--sky)', soft: 'var(--sky-soft)' },
+ { id: 'm4', name: 'Sushi', age: 1, breed: 'Holland Lop', species: 'rabbit', dist: '1.5 km', bio: 'Vegetable connoisseur. Loaf 24/7.', color: 'var(--lilac)', soft: 'var(--lilac-soft)' },
+];
+
+function MatchScreen({ navigate, openSwitcher, activePet, motif }) {
+ const [stack, setStack] = React.useState(DEMO_MATCHES);
+ const [dir, setDir] = React.useState(null); // {id, x, rot}
+ const [matched, setMatched] = React.useState(null);
+ const [drag, setDrag] = React.useState(null); // {id, dx, dy}
+
+ function swipe(card, direction) {
+ setDir({ id: card.id, dir: direction });
+ setTimeout(() => {
+ setStack(s => s.filter(x => x.id !== card.id));
+ setDir(null);
+ setDrag(null);
+ if (direction === 'right' && card.id === 'm1') {
+ setTimeout(() => setMatched(card), 250);
+ }
+ }, 340);
+ }
+
+ const pet = DEMO_PETS.find(p => p.id === activePet) || DEMO_PETS[0];
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
MATCH AS
+
+ {pet.name} {I.chevronDown(14)}
+
+
+
+
+ {I.comment(20)}
+ {I.filter(20)}
+
+
+
+
+
+ 🔥 Within 5 km
+
+
+ Playdates ON
+
+
+
+ {/* Stack */}
+
+ {stack.length === 0 && (
+ setStack(DEMO_MATCHES)}/>
+ )}
+ {stack.slice(0, 3).reverse().map((c, idxFromTop) => {
+ const i = 2 - idxFromTop; // 0 = back, 2 = front
+ const isFront = i === 0;
+ const isSwiping = dir && dir.id === c.id;
+ const d = drag && drag.id === c.id ? drag : null;
+ const tx = isSwiping ? (dir.dir === 'right' ? 600 : -600) : (d ? d.dx : 0);
+ const rot = isSwiping ? (dir.dir === 'right' ? 22 : -22) : (d ? d.dx / 14 : 0);
+ const liked = d ? d.dx > 60 : (isSwiping && dir.dir === 'right');
+ const disliked = d ? d.dx < -60 : (isSwiping && dir.dir === 'left');
+ return (
+ isFront && setDrag({ id: c.id, dx: 0, dy: 0, ox: x, oy: y })}
+ onMove={(x,y) => isFront && drag && drag.id === c.id && setDrag({ ...drag, dx: x - drag.ox, dy: y - drag.oy })}
+ onEnd={() => {
+ if (!isFront || !drag) return;
+ if (drag.dx > 100) swipe(c, 'right');
+ else if (drag.dx < -100) swipe(c, 'left');
+ else setDrag(null);
+ }}
+ />
+ );
+ })}
+
+
+ {/* Actions */}
+
+ stack[0] && swipe(stack[0], 'left')} size={56}>✕
+ stack[0] && swipe(stack[0], 'right')} size={48}>⭐
+ stack[0] && swipe(stack[0], 'right')} size={72}>
+ 🐾
+
+ 🦴
+ ↺
+
+
+ {/* Mutual match overlay */}
+ {matched &&
setMatched(null)}/>}
+
+ );
+}
+
+function RoundAction({ children, onClick, bg, color, size = 56 }) {
+ return (
+ e.currentTarget.style.transform = 'translateY(3px)'}
+ onPointerUp={e => e.currentTarget.style.transform = ''}
+ onPointerLeave={e => e.currentTarget.style.transform = ''}
+ >{children}
+ );
+}
+
+function SwipeCard({ card, depth, tx, rot, liked, disliked, swiping, onStart, onMove, onEnd }) {
+ const scale = 1 - depth * 0.05;
+ const ty = depth * 12;
+ return (
+ { e.target.setPointerCapture && e.target.setPointerCapture(e.pointerId); onStart(e.clientX, e.clientY); }}
+ onPointerMove={e => onMove(e.clientX, e.clientY)}
+ onPointerUp={onEnd}
+ onPointerCancel={onEnd}
+ style={{
+ position: 'absolute', inset: 0,
+ transform: `translate(${tx}px, ${ty}px) scale(${scale}) rotate(${rot}deg)`,
+ transition: swiping ? 'transform 320ms cubic-bezier(.4,0,.6,1)' : (tx === 0 ? 'transform 280ms cubic-bezier(.3,1.4,.5,1)' : 'none'),
+ borderRadius: 32, overflow: 'hidden',
+ background: `linear-gradient(135deg, ${card.soft}, ${card.color})`,
+ boxShadow: '0 24px 50px -20px rgba(0,0,0,0.3)',
+ display: 'flex', flexDirection: 'column',
+ touchAction: 'none',
+ cursor: depth === 0 ? 'grab' : 'default',
+ }}
+ >
+ {/* Photo block */}
+
+
+ {SPECIES.find(s => s.id === card.species)?.emoji}
+
+
+ {/* Like/Nope stamps */}
+ {liked &&
}
+ {disliked &&
}
+
+ {/* Distance pill */}
+
+ {I.location(12, '#fff')} {card.dist}
+
+
+ {/* Info gradient */}
+
+
+
{card.name},
+ {card.age}
+
+
{card.breed}
+
{card.bio}
+
+ {/* Trait chips */}
+
+ {['🦴 Treat fiend', '🎾 Playful', '🤗 Cuddly'].map((t,i) => (
+ {t}
+ ))}
+
+
+
+
+ );
+}
+
+function Stamp({ color, rotate, label, right }) {
+ return (
+ {label}
+ );
+}
+
+function EmptyStack({ onReset }) {
+ return (
+
+
🐾
+
That's the pack near you!
+
Widen your radius or check back later — new pups join every day.
+
Start over
+
+ );
+}
+
+// ─── Mutual match overlay ──────────────────────────────────
+function MutualMatch({ other, self, onClose }) {
+ const selfSp = SPECIES.find(s => s.id === self.species);
+ const otherSp = SPECIES.find(s => s.id === other.species);
+
+ return (
+
+ {/* Confetti */}
+
+
+ {/* Floating paws bg */}
+
+ {[[10,10,38,-10],[80,18,30,15],[15,72,42,-20],[78,80,36,18],[50,30,28,8]].map(([x,y,s,r],i) => (
+
+ {I.paw(s, '#fff')}
+
+ ))}
+
+
+ {/* Headline */}
+
+
+
+ It's aPawfect Match!
+
+
+ {self.name} & {other.name} both said WOOF
+
+
+
+ {/* The two avatars pressing paws */}
+
+
+ {/* Connecting paw */}
+
+ {I.paw(36, 'var(--ink-950)')}
+
+
+
+
+ {/* Actions */}
+
+
+ Send a tail wag 🐾
+
+
Keep swiping
+
+
+ );
+}
+
+Object.assign(window, { MatchScreen });
diff --git a/PetFolio Redesign/onboarding.jsx b/PetFolio Redesign/onboarding.jsx
new file mode 100644
index 0000000..001bd71
--- /dev/null
+++ b/PetFolio Redesign/onboarding.jsx
@@ -0,0 +1,301 @@
+// onboarding.jsx — 5-step playful pet quiz with species-reactive backgrounds
+
+function Onboarding({ onDone }) {
+ const [step, setStep] = React.useState(0);
+ const [data, setData] = React.useState({
+ species: 'dog',
+ name: '',
+ age: 24, // months
+ personality: [],
+ color: 'gold',
+ });
+
+ const sp = SPECIES.find(s => s.id === data.species) || SPECIES[0];
+
+ const total = 5;
+ const next = () => setStep(s => Math.min(s + 1, total));
+ const back = () => setStep(s => Math.max(s - 1, 0));
+
+ // Step contents
+ const Steps = [
+ () => ,
+ () => ,
+ () => ,
+ () => ,
+ () => { next(); setTimeout(onDone, 1400); }} sp={sp}/>,
+ ];
+ const Step = Steps[step] || (() => );
+
+ return (
+
+ {/* Decorative paws floating */}
+
+
+ {/* Header */}
+
+ {step > 0 && step < total && (
+
{I.back(22)}
+ )}
+ {step > 0 && step < total &&
}
+
+
+ {/* Step content */}
+
+
+
+
+ );
+}
+
+function ProgressDots({ count, active }) {
+ return (
+
+ {Array.from({ length: count }).map((_, i) => (
+
+ ))}
+
+ );
+}
+
+function FloatingPaws({ species }) {
+ return (
+
+ {[[14,18,40,12],[80,22,28,-8],[12,68,32,18],[78,72,46,-12],[42,38,22,4]].map(([x,y,s,r],i) => (
+
+ {I.paw(s, species.color)}
+
+ ))}
+
+ );
+}
+
+// ─── Step 0: hello / welcome ───
+function StepHello({ next, sp }) {
+ return (
+
+
+
+ Hi! I'm PetFolio .
+
+
+ Your pet's whole life — feeds, friends, health, treats — in one cozy place.
+
+
+
Start the tail-wag
+
I already have an account
+
+
+ );
+}
+
+// ─── Step 1: species select (reactive bg) ───
+function StepSpecies({ data, setData, next }) {
+ return (
+
+
+ Who are we welcoming home?
+
+
Pick your pet — the app will dress up to match.
+
+
+ {SPECIES.map(s => {
+ const selected = data.species === s.id;
+ return (
+
setData(d => ({ ...d, species: s.id }))} style={{
+ background: selected ? s.color : 'var(--surface)',
+ borderRadius: 24, border: 'none', cursor: 'pointer',
+ padding: '20px 8px 14px',
+ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6,
+ boxShadow: selected ? `0 10px 24px -10px ${s.color}` : 'var(--shadow-soft)',
+ transform: selected ? 'translateY(-2px) scale(1.02)' : 'none',
+ transition: 'all 240ms cubic-bezier(.5,1.7,.5,1)',
+ border: `2px solid ${selected ? s.color : 'transparent'}`,
+ }}>
+ {s.emoji}
+ {s.label}
+
+ );
+ })}
+
+
+
+
+ );
+}
+
+// ─── Step 2: name ───
+function StepName({ data, setData, next, sp }) {
+ return (
+
+
+ What's their name ?
+
+
The one you whisper when no one's watching.
+
+
+ setData(d => ({ ...d, name: e.target.value }))} placeholder="e.g. Mochi, Biscuit, Mr Whiskers" style={{
+ width: '100%', border: 'none', outline: 'none',
+ padding: '20px 22px', fontSize: 22, fontWeight: 700, color: 'var(--ink-950)',
+ fontFamily: 'inherit', background: 'transparent', borderRadius: 22,
+ }}/>
+
+
+
+ {['Mochi','Biscuit','Pepper','Luna','Coco','Tofu'].map(n => (
+ setData(d => ({ ...d, name: n }))} style={{
+ padding: '8px 14px', borderRadius: 999, border: '1.5px solid var(--line-2)',
+ background: 'var(--surface)', fontSize: 13, fontWeight: 700, color: 'var(--ink-700)',
+ cursor: 'pointer',
+ }}>{n}
+ ))}
+
+
+
+
+ );
+}
+
+// ─── Step 3: age (bone slider!) ───
+function StepAge({ data, setData, next, sp }) {
+ const years = (data.age / 12);
+ return (
+
+
+ How old is {data.name || 'they'} ?
+
+
Slide the bone — it's about right, no need to be exact.
+
+
+
+ {years < 1 ? data.age : Math.floor(years)}
+
+ {years < 1 ? (data.age === 1 ? 'month' : 'months') : (Math.floor(years) === 1 ? 'year young' : 'years young')}
+
+
+ setData(d => ({ ...d, age: v }))} min={1} max={216} color={sp.color}/>
+
+ PUPPY ADULT SENIOR
+
+
+
+
+
+ );
+}
+
+// ─── Step 4: personality chips ───
+const PERSONALITY_TRAITS = [
+ { id: 'cuddly', label: 'Cuddly', emoji: '🤗' },
+ { id: 'playful', label: 'Playful', emoji: '🎾' },
+ { id: 'shy', label: 'Shy', emoji: '🙈' },
+ { id: 'chaotic', label: 'Chaos goblin', emoji: '😈' },
+ { id: 'foodie', label: 'Treat fiend', emoji: '🦴' },
+ { id: 'adventurer', label: 'Adventurer', emoji: '🏕️' },
+ { id: 'lazy', label: 'Couch potato', emoji: '🛋️' },
+ { id: 'chatty', label: 'Chatty', emoji: '💬' },
+ { id: 'protective', label: 'Loyal guard', emoji: '🛡️' },
+];
+
+function StepPersonality({ data, setData, next, sp }) {
+ const toggle = id => setData(d => ({
+ ...d,
+ personality: d.personality.includes(id)
+ ? d.personality.filter(x => x !== id)
+ : [...d.personality, id],
+ }));
+ return (
+
+
+ How would you describe theirvibe ?
+
+
Pick a few. We won't tell anyone.
+
+
+ {PERSONALITY_TRAITS.map(t => {
+ const on = data.personality.includes(t.id);
+ return (
+ toggle(t.id)} style={{
+ padding: '12px 18px', borderRadius: 999,
+ border: `2px solid ${on ? sp.color : 'var(--line-2)'}`,
+ background: on ? sp.color : 'var(--surface)',
+ color: on ? '#fff' : 'var(--ink-950)',
+ fontSize: 14, fontWeight: 800, cursor: 'pointer',
+ display: 'inline-flex', alignItems: 'center', gap: 8,
+ transition: 'all 200ms cubic-bezier(.5,1.7,.5,1)',
+ transform: on ? 'scale(1.04)' : 'scale(1)',
+ boxShadow: on ? `0 6px 14px -6px ${sp.color}` : 'none',
+ fontFamily: 'inherit',
+ }}>
+ {t.emoji} {t.label}
+
+ );
+ })}
+
+
+
+
Meet {data.name || 'them'}
+
+
+ );
+}
+
+function StepDone({ sp, data }) {
+ return (
+
+
+
+ Welcome, {data.name}!
+
+
Let's set up their world…
+
+
+ );
+}
+
+Object.assign(window, { Onboarding });
diff --git a/PetFolio Redesign/social.jsx b/PetFolio Redesign/social.jsx
new file mode 100644
index 0000000..1a840b3
--- /dev/null
+++ b/PetFolio Redesign/social.jsx
@@ -0,0 +1,276 @@
+// social.jsx — Social feed with reaction bursts
+
+const DEMO_POSTS = [
+ {
+ id: 'p1', user: 'Tommy', species: 'dog', when: '2h', loc: 'Riverside Park',
+ text: 'Found my new favorite stick. Will not be sharing. 🌳',
+ photo: { color: 'var(--mint)', soft: 'var(--mint-soft)', emoji: '🌲' },
+ likes: 142, comments: 24, reaction: 'paw',
+ },
+ {
+ id: 'p2', user: 'Goldy', species: 'fish', when: '5h', loc: 'Kitchen Tank',
+ text: 'New plant came today. 10/10 would nibble again. Thanks @PetfolioMarket 💚',
+ photo: { color: 'var(--sky)', soft: 'var(--sky-soft)', emoji: '🪴' },
+ likes: 38, comments: 6, reaction: 'heart',
+ },
+ {
+ id: 'p3', user: 'Rex', species: 'reptile', when: '1d', loc: 'Sun Rock',
+ text: 'POV: you are warm.',
+ photo: { color: 'var(--sunny)', soft: 'var(--sunny-soft)', emoji: '☀️' },
+ likes: 211, comments: 41, reaction: 'star',
+ },
+];
+
+const REACTION_KINDS = [
+ { id: 'paw', emoji: '🐾', color: 'var(--tangerine)' },
+ { id: 'heart', emoji: '❤️', color: 'var(--poppy)' },
+ { id: 'treat', emoji: '🦴', color: 'var(--sunny)' },
+ { id: 'star', emoji: '⭐', color: 'var(--lilac)' },
+];
+
+function SocialScreen({ navigate, motif }) {
+ const [bursts, setBursts] = React.useState({}); // postId -> array of {id,x,y,kind}
+ const [reacted, setReacted] = React.useState({});
+ const [pickerOpen, setPickerOpen] = React.useState(null);
+
+ function fireBurst(postId, kind, x, y) {
+ const count = 8 + Math.floor(Math.random() * 4);
+ const items = Array.from({ length: count }).map((_, i) => ({
+ id: Date.now() + i, x: x + (Math.random()-0.5)*40, y, kind,
+ }));
+ setBursts(b => ({ ...b, [postId]: [...(b[postId] || []), ...items] }));
+ setTimeout(() => {
+ setBursts(b => {
+ const cur = b[postId] || [];
+ const ids = items.map(i => i.id);
+ return { ...b, [postId]: cur.filter(c => !ids.includes(c.id)) };
+ });
+ }, 1100);
+ setReacted(r => ({ ...r, [postId]: kind }));
+ setPickerOpen(null);
+ }
+
+ return (
+
+ {/* Sticky header */}
+
+
+
Pawsfeed
+
Your pack · 124 new posts today
+
+
+ {I.search(20)}
+ {I.send(20, '#fff')}
+
+
+
+ {/* Story bar */}
+
+
+ {DEMO_PETS.slice(0,5).map(p => (
+
+ ))}
+
+
+ {/* Posts */}
+
+ {DEMO_POSTS.map(post => (
+
setPickerOpen(o => o === post.id ? null : post.id)}
+ onFire={(kind, x, y) => fireBurst(post.id, kind, x, y)}
+ />
+ ))}
+ You're all caught up 🐾
+
+
+ );
+}
+
+function StoryAdd() {
+ return (
+
+
+
+ {I.plus(14, '#fff')}
+
+
📸
+
+
Your story
+
+ );
+}
+
+function PostCard({ post, bursts, picker, reacted, onOpenPicker, onFire }) {
+ const sp = SPECIES.find(s => s.id === post.species) || SPECIES[0];
+ const reactionRef = React.useRef(null);
+
+ function handleReact(kind, e) {
+ const rect = reactionRef.current?.getBoundingClientRect();
+ const parentRect = reactionRef.current?.offsetParent?.getBoundingClientRect();
+ const x = rect ? rect.left - (parentRect?.left || 0) + rect.width/2 : 0;
+ const y = rect ? rect.top - (parentRect?.top || 0) + 8 : 0;
+ onFire(kind, x, y);
+ }
+
+ function quickReact(e) {
+ if (reacted) return;
+ handleReact('paw');
+ }
+
+ const totalLikes = post.likes + (reacted ? 1 : 0);
+
+ return (
+
+ {/* Header */}
+
+
+
+
{post.user}
+
+ {I.location(11, 'var(--ink-500)')} {post.loc} · {post.when}
+
+
+
···
+
+
+ {/* Photo */}
+
+
+ {/* Text */}
+ {post.text}
+
+ {/* Reaction stack visualizer */}
+
+
+ {['🐾','❤️','🦴'].map((e,i) => (
+
{e}
+ ))}
+
+
+ {totalLikes} reacted · {post.comments} comments
+
+
+
+ {/* Actions */}
+
+
+
+
+
+
+ {/* Reaction picker */}
+ {picker && (
+
+ {REACTION_KINDS.map(r => (
+ handleReact(r.id)} style={{
+ width: 42, height: 42, borderRadius: '50%', border: 'none', cursor: 'pointer',
+ background: 'var(--cream-2)', fontSize: 24,
+ transition: 'transform 150ms cubic-bezier(.5,1.7,.5,1)',
+ fontFamily: 'inherit',
+ }}
+ onMouseEnter={e => e.currentTarget.style.transform = 'scale(1.18)'}
+ onMouseLeave={e => e.currentTarget.style.transform = 'scale(1)'}
+ >{r.emoji}
+ ))}
+
+ )}
+
+ {/* Bursts */}
+ {bursts && bursts.length > 0 &&
}
+
+
+ );
+}
+
+function ReactionButton({ reacted, onClick, onLongPress, refEl }) {
+ const timer = React.useRef(null);
+ function down() {
+ timer.current = setTimeout(() => { onLongPress(); timer.current = null; }, 280);
+ }
+ function up(e) {
+ if (timer.current) {
+ clearTimeout(timer.current);
+ timer.current = null;
+ onClick(e);
+ }
+ }
+ const r = REACTION_KINDS.find(x => x.id === reacted);
+ return (
+ { if (timer.current) { clearTimeout(timer.current); timer.current = null; } }} style={{
+ flex: 1, height: 44, border: 'none', background: 'transparent', cursor: 'pointer',
+ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
+ borderRadius: 14, color: r ? r.color : 'var(--ink-700)',
+ fontWeight: 800, fontSize: 13,
+ fontFamily: 'inherit',
+ }}>
+ {r ? {r.emoji} : I.pawOutline(20)}
+ {r ? r.id[0].toUpperCase() + r.id.slice(1) : 'React'}
+
+ );
+}
+
+function ActionBtn({ icon, label }) {
+ return (
+
+ {icon} {label}
+
+ );
+}
+
+Object.assign(window, { SocialScreen });
diff --git a/PetFolio Redesign/tweaks-panel.jsx b/PetFolio Redesign/tweaks-panel.jsx
new file mode 100644
index 0000000..bed5d66
--- /dev/null
+++ b/PetFolio Redesign/tweaks-panel.jsx
@@ -0,0 +1,530 @@
+
+// tweaks-panel.jsx
+// Reusable Tweaks shell + form-control helpers.
+//
+// Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode,
+// posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so
+// individual prototypes don't re-roll it. Ships a consistent set of controls so you
+// don't hand-draw , segmented radios, steppers, etc.
+//
+// Usage (in an HTML file that loads React + Babel):
+//
+// const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
+// "primaryColor": "#D97757",
+// "palette": ["#D97757", "#29261b", "#f6f4ef"],
+// "fontSize": 16,
+// "density": "regular",
+// "dark": false
+// }/*EDITMODE-END*/;
+//
+// function App() {
+// const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
+// return (
+//
+// Hello
+//
+//
+// setTweak('fontSize', v)} />
+// setTweak('density', v)} />
+//
+// setTweak('primaryColor', v)} />
+// setTweak('palette', v)} />
+// setTweak('dark', v)} />
+//
+//
+// );
+// }
+//
+// ─────────────────────────────────────────────────────────────────────────────
+
+const __TWEAKS_STYLE = `
+ .twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px;
+ max-height:calc(100vh - 32px);display:flex;flex-direction:column;
+ transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom right;
+ background:rgba(250,249,247,.78);color:#29261b;
+ -webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%);
+ border:.5px solid rgba(255,255,255,.6);border-radius:14px;
+ box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18);
+ font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden}
+ .twk-hd{display:flex;align-items:center;justify-content:space-between;
+ padding:10px 8px 10px 14px;cursor:move;user-select:none}
+ .twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em}
+ .twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55);
+ width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1}
+ .twk-x:hover{background:rgba(0,0,0,.06);color:#29261b}
+ .twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px;
+ overflow-y:auto;overflow-x:hidden;min-height:0;
+ scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
+ .twk-body::-webkit-scrollbar{width:8px}
+ .twk-body::-webkit-scrollbar-track{background:transparent;margin:2px}
+ .twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px;
+ border:2px solid transparent;background-clip:content-box}
+ .twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25);
+ border:2px solid transparent;background-clip:content-box}
+ .twk-row{display:flex;flex-direction:column;gap:5px}
+ .twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px}
+ .twk-lbl{display:flex;justify-content:space-between;align-items:baseline;
+ color:rgba(41,38,27,.72)}
+ .twk-lbl>span:first-child{font-weight:500}
+ .twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums}
+
+ .twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;
+ color:rgba(41,38,27,.45);padding:10px 0 0}
+ .twk-sect:first-child{padding-top:0}
+
+ .twk-field{appearance:none;box-sizing:border-box;width:100%;min-width:0;height:26px;padding:0 8px;
+ border:.5px solid rgba(0,0,0,.1);border-radius:7px;
+ background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none}
+ .twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)}
+ select.twk-field{padding-right:22px;
+ background-image:url("data:image/svg+xml;utf8, ");
+ background-repeat:no-repeat;background-position:right 8px center}
+
+ .twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0;
+ border-radius:999px;background:rgba(0,0,0,.12);outline:none}
+ .twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;
+ width:14px;height:14px;border-radius:50%;background:#fff;
+ border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
+ .twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%;
+ background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
+
+ .twk-seg{position:relative;display:flex;padding:2px;border-radius:8px;
+ background:rgba(0,0,0,.06);user-select:none}
+ .twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px;
+ background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12);
+ transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s}
+ .twk-seg.dragging .twk-seg-thumb{transition:none}
+ .twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0;
+ background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px;
+ border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2;
+ overflow-wrap:anywhere}
+
+ .twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px;
+ background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0}
+ .twk-toggle[data-on="1"]{background:#34c759}
+ .twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;
+ background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s}
+ .twk-toggle[data-on="1"] i{transform:translateX(14px)}
+
+ .twk-num{display:flex;align-items:center;box-sizing:border-box;min-width:0;height:26px;padding:0 0 0 8px;
+ border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)}
+ .twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize;
+ user-select:none;padding-right:8px}
+ .twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent;
+ font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0;
+ outline:none;color:inherit;-moz-appearance:textfield}
+ .twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{
+ -webkit-appearance:none;margin:0}
+ .twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)}
+
+ .twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px;
+ background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default}
+ .twk-btn:hover{background:rgba(0,0,0,.88)}
+ .twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit}
+ .twk-btn.secondary:hover{background:rgba(0,0,0,.1)}
+
+ .twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px;
+ border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default;
+ background:transparent;flex-shrink:0}
+ .twk-swatch::-webkit-color-swatch-wrapper{padding:0}
+ .twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px}
+ .twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px}
+
+ .twk-chips{display:flex;gap:6px}
+ .twk-chip{position:relative;appearance:none;flex:1;min-width:0;height:46px;
+ padding:0;border:0;border-radius:6px;overflow:hidden;cursor:default;
+ box-shadow:0 0 0 .5px rgba(0,0,0,.12),0 1px 2px rgba(0,0,0,.06);
+ transition:transform .12s cubic-bezier(.3,.7,.4,1),box-shadow .12s}
+ .twk-chip:hover{transform:translateY(-1px);
+ box-shadow:0 0 0 .5px rgba(0,0,0,.18),0 4px 10px rgba(0,0,0,.12)}
+ .twk-chip[data-on="1"]{box-shadow:0 0 0 1.5px rgba(0,0,0,.85),
+ 0 2px 6px rgba(0,0,0,.15)}
+ .twk-chip>span{position:absolute;top:0;bottom:0;right:0;width:34%;
+ display:flex;flex-direction:column;box-shadow:-1px 0 0 rgba(0,0,0,.1)}
+ .twk-chip>span>i{flex:1;box-shadow:0 -1px 0 rgba(0,0,0,.1)}
+ .twk-chip>span>i:first-child{box-shadow:none}
+ .twk-chip svg{position:absolute;top:6px;left:6px;width:13px;height:13px;
+ filter:drop-shadow(0 1px 1px rgba(0,0,0,.3))}
+`;
+
+// ── useTweaks ───────────────────────────────────────────────────────────────
+// Single source of truth for tweak values. setTweak persists via the host
+// (__edit_mode_set_keys → host rewrites the EDITMODE block on disk).
+function useTweaks(defaults) {
+ const [values, setValues] = React.useState(defaults);
+ // Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a
+ // useState-style call doesn't write a "[object Object]" key into the persisted
+ // JSON block.
+ const setTweak = React.useCallback((keyOrEdits, val) => {
+ const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null
+ ? keyOrEdits : { [keyOrEdits]: val };
+ setValues((prev) => ({ ...prev, ...edits }));
+ window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*');
+ // Same-window signal so in-page listeners (deck-stage rail thumbnails)
+ // can react — the parent message only reaches the host, not peers.
+ window.dispatchEvent(new CustomEvent('tweakchange', { detail: edits }));
+ }, []);
+ return [values, setTweak];
+}
+
+// ── TweaksPanel ─────────────────────────────────────────────────────────────
+// Floating shell. Registers the protocol listener BEFORE announcing
+// availability — if the announce ran first, the host's activate could land
+// before our handler exists and the toolbar toggle would silently no-op.
+// The close button posts __edit_mode_dismissed so the host's toolbar toggle
+// flips off in lockstep; the host echoes __deactivate_edit_mode back which
+// is what actually hides the panel.
+function TweaksPanel({ title = 'Tweaks', children }) {
+ const [open, setOpen] = React.useState(false);
+ const dragRef = React.useRef(null);
+ const offsetRef = React.useRef({ x: 16, y: 16 });
+ const PAD = 16;
+
+ const clampToViewport = React.useCallback(() => {
+ const panel = dragRef.current;
+ if (!panel) return;
+ const w = panel.offsetWidth, h = panel.offsetHeight;
+ const maxRight = Math.max(PAD, window.innerWidth - w - PAD);
+ const maxBottom = Math.max(PAD, window.innerHeight - h - PAD);
+ offsetRef.current = {
+ x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)),
+ y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)),
+ };
+ panel.style.right = offsetRef.current.x + 'px';
+ panel.style.bottom = offsetRef.current.y + 'px';
+ }, []);
+
+ React.useEffect(() => {
+ if (!open) return;
+ clampToViewport();
+ if (typeof ResizeObserver === 'undefined') {
+ window.addEventListener('resize', clampToViewport);
+ return () => window.removeEventListener('resize', clampToViewport);
+ }
+ const ro = new ResizeObserver(clampToViewport);
+ ro.observe(document.documentElement);
+ return () => ro.disconnect();
+ }, [open, clampToViewport]);
+
+ React.useEffect(() => {
+ const onMsg = (e) => {
+ const t = e?.data?.type;
+ if (t === '__activate_edit_mode') setOpen(true);
+ else if (t === '__deactivate_edit_mode') setOpen(false);
+ };
+ window.addEventListener('message', onMsg);
+ window.parent.postMessage({ type: '__edit_mode_available' }, '*');
+ return () => window.removeEventListener('message', onMsg);
+ }, []);
+
+ const dismiss = () => {
+ setOpen(false);
+ window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*');
+ };
+
+ const onDragStart = (e) => {
+ const panel = dragRef.current;
+ if (!panel) return;
+ const r = panel.getBoundingClientRect();
+ const sx = e.clientX, sy = e.clientY;
+ const startRight = window.innerWidth - r.right;
+ const startBottom = window.innerHeight - r.bottom;
+ const move = (ev) => {
+ offsetRef.current = {
+ x: startRight - (ev.clientX - sx),
+ y: startBottom - (ev.clientY - sy),
+ };
+ clampToViewport();
+ };
+ const up = () => {
+ window.removeEventListener('mousemove', move);
+ window.removeEventListener('mouseup', up);
+ };
+ window.addEventListener('mousemove', move);
+ window.addEventListener('mouseup', up);
+ };
+
+ if (!open) return null;
+ return (
+ <>
+
+
+
+ {title}
+ e.stopPropagation()}
+ onClick={dismiss}>✕
+
+
+ {children}
+
+
+ >
+ );
+}
+
+// ── Layout helpers ──────────────────────────────────────────────────────────
+
+function TweakSection({ label, children }) {
+ return (
+ <>
+ {label}
+ {children}
+ >
+ );
+}
+
+function TweakRow({ label, value, children, inline = false }) {
+ return (
+
+
+ {label}
+ {value != null && {value} }
+
+ {children}
+
+ );
+}
+
+// ── Controls ────────────────────────────────────────────────────────────────
+
+function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) {
+ return (
+
+ onChange(Number(e.target.value))} />
+
+ );
+}
+
+function TweakToggle({ label, value, onChange }) {
+ return (
+
+
{label}
+
onChange(!value)}>
+
+ );
+}
+
+function TweakRadio({ label, value, options, onChange }) {
+ const trackRef = React.useRef(null);
+ const [dragging, setDragging] = React.useState(false);
+ // The active value is read by pointer-move handlers attached for the lifetime
+ // of a drag — ref it so a stale closure doesn't fire onChange for every move.
+ const valueRef = React.useRef(value);
+ valueRef.current = value;
+
+ // Segments wrap mid-word once per-segment width runs out. The track is
+ // ~248px (280 panel − 28 body pad − 4 seg pad), each button loses 12px
+ // to its own padding, and 11.5px system-ui averages ~6.3px/char — so 2
+ // options fit ~16 chars each, 3 fit ~10. Past that (or >3 options), fall
+ // back to a dropdown rather than wrap.
+ const labelLen = (o) => String(typeof o === 'object' ? o.label : o).length;
+ const maxLen = options.reduce((m, o) => Math.max(m, labelLen(o)), 0);
+ const fitsAsSegments = maxLen <= ({ 2: 16, 3: 10 }[options.length] ?? 0);
+ if (!fitsAsSegments) {
+ // emits strings — map back to the original option value so the
+ // fallback stays type-preserving (numbers, booleans) like the segment path.
+ const resolve = (s) => {
+ const m = options.find((o) => String(typeof o === 'object' ? o.value : o) === s);
+ return m === undefined ? s : typeof m === 'object' ? m.value : m;
+ };
+ return onChange(resolve(s))} />;
+ }
+ const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o }));
+ const idx = Math.max(0, opts.findIndex((o) => o.value === value));
+ const n = opts.length;
+
+ const segAt = (clientX) => {
+ const r = trackRef.current.getBoundingClientRect();
+ const inner = r.width - 4;
+ const i = Math.floor(((clientX - r.left - 2) / inner) * n);
+ return opts[Math.max(0, Math.min(n - 1, i))].value;
+ };
+
+ const onPointerDown = (e) => {
+ setDragging(true);
+ const v0 = segAt(e.clientX);
+ if (v0 !== valueRef.current) onChange(v0);
+ const move = (ev) => {
+ if (!trackRef.current) return;
+ const v = segAt(ev.clientX);
+ if (v !== valueRef.current) onChange(v);
+ };
+ const up = () => {
+ setDragging(false);
+ window.removeEventListener('pointermove', move);
+ window.removeEventListener('pointerup', up);
+ };
+ window.addEventListener('pointermove', move);
+ window.addEventListener('pointerup', up);
+ };
+
+ return (
+
+
+
+ {opts.map((o) => (
+
+ {o.label}
+
+ ))}
+
+
+ );
+}
+
+function TweakSelect({ label, value, options, onChange }) {
+ return (
+
+ onChange(e.target.value)}>
+ {options.map((o) => {
+ const v = typeof o === 'object' ? o.value : o;
+ const l = typeof o === 'object' ? o.label : o;
+ return {l} ;
+ })}
+
+
+ );
+}
+
+function TweakText({ label, value, placeholder, onChange }) {
+ return (
+
+ onChange(e.target.value)} />
+
+ );
+}
+
+function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) {
+ const clamp = (n) => {
+ if (min != null && n < min) return min;
+ if (max != null && n > max) return max;
+ return n;
+ };
+ const startRef = React.useRef({ x: 0, val: 0 });
+ const onScrubStart = (e) => {
+ e.preventDefault();
+ startRef.current = { x: e.clientX, val: value };
+ const decimals = (String(step).split('.')[1] || '').length;
+ const move = (ev) => {
+ const dx = ev.clientX - startRef.current.x;
+ const raw = startRef.current.val + dx * step;
+ const snapped = Math.round(raw / step) * step;
+ onChange(clamp(Number(snapped.toFixed(decimals))));
+ };
+ const up = () => {
+ window.removeEventListener('pointermove', move);
+ window.removeEventListener('pointerup', up);
+ };
+ window.addEventListener('pointermove', move);
+ window.addEventListener('pointerup', up);
+ };
+ return (
+
+ {label}
+ onChange(clamp(Number(e.target.value)))} />
+ {unit && {unit} }
+
+ );
+}
+
+// Relative-luminance contrast pick — checkmarks drawn over a swatch need to
+// read on both #111 and #fafafa without per-option configuration. Hex input
+// only (#rgb / #rrggbb); named or rgb()/hsl() colors fall through to "light".
+function __twkIsLight(hex) {
+ const h = String(hex).replace('#', '');
+ const x = h.length === 3 ? h.replace(/./g, (c) => c + c) : h.padEnd(6, '0');
+ const n = parseInt(x.slice(0, 6), 16);
+ if (Number.isNaN(n)) return true;
+ const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
+ return r * 299 + g * 587 + b * 114 > 148000;
+}
+
+const __TwkCheck = ({ light }) => (
+
+
+
+);
+
+// TweakColor — curated color/palette picker. Each option is either a single
+// hex string or an array of 1-5 hex strings; the card adapts — a lone color
+// renders solid, a palette renders colors[0] as the hero (left ~2/3) with the
+// rest stacked in a sharp column on the right. onChange emits the
+// option in the shape it was passed (string stays string, array stays array).
+// Without options it falls back to the native color input for back-compat.
+function TweakColor({ label, value, options, onChange }) {
+ if (!options || !options.length) {
+ return (
+
+
{label}
+
onChange(e.target.value)} />
+
+ );
+ }
+ // Native emits lowercase hex per the HTML spec, so
+ // compare case-insensitively. String() guards JSON.stringify(undefined),
+ // which returns the primitive undefined (no .toLowerCase).
+ const key = (o) => String(JSON.stringify(o)).toLowerCase();
+ const cur = key(value);
+ return (
+
+
+ {options.map((o, i) => {
+ const colors = Array.isArray(o) ? o : [o];
+ const [hero, ...rest] = colors;
+ const sup = rest.slice(0, 4);
+ const on = key(o) === cur;
+ return (
+ onChange(o)}>
+ {sup.length > 0 && (
+
+ {sup.map((c, j) => )}
+
+ )}
+ {on && <__TwkCheck light={__twkIsLight(hero)} />}
+
+ );
+ })}
+
+
+ );
+}
+
+function TweakButton({ label, onClick, secondary = false }) {
+ return (
+ {label}
+ );
+}
+
+Object.assign(window, {
+ useTweaks, TweaksPanel, TweakSection, TweakRow,
+ TweakSlider, TweakToggle, TweakRadio, TweakSelect,
+ TweakText, TweakNumber, TweakColor, TweakButton,
+});
diff --git a/PetFolio-UI-Screens/Screenshot_1779708162.png b/PetFolio-UI-Screens/Screenshot_1779708162.png
new file mode 100644
index 0000000..ce90668
Binary files /dev/null and b/PetFolio-UI-Screens/Screenshot_1779708162.png differ
diff --git a/PetFolio-UI-Screens/Screenshot_1779708204.png b/PetFolio-UI-Screens/Screenshot_1779708204.png
new file mode 100644
index 0000000..42baf78
Binary files /dev/null and b/PetFolio-UI-Screens/Screenshot_1779708204.png differ
diff --git a/PetFolio-UI-Screens/Screenshot_1779708207.png b/PetFolio-UI-Screens/Screenshot_1779708207.png
new file mode 100644
index 0000000..f78990f
Binary files /dev/null and b/PetFolio-UI-Screens/Screenshot_1779708207.png differ
diff --git a/PetFolio-UI-Screens/Screenshot_1779708218.png b/PetFolio-UI-Screens/Screenshot_1779708218.png
new file mode 100644
index 0000000..a53f5bd
Binary files /dev/null and b/PetFolio-UI-Screens/Screenshot_1779708218.png differ
diff --git a/PetFolio-UI-Screens/Screenshot_1779708220.png b/PetFolio-UI-Screens/Screenshot_1779708220.png
new file mode 100644
index 0000000..37c90c0
Binary files /dev/null and b/PetFolio-UI-Screens/Screenshot_1779708220.png differ
diff --git a/PetFolio-UI-Screens/Screenshot_1779708227.png b/PetFolio-UI-Screens/Screenshot_1779708227.png
new file mode 100644
index 0000000..bd41941
Binary files /dev/null and b/PetFolio-UI-Screens/Screenshot_1779708227.png differ
diff --git a/PetFolio-UI-Screens/Screenshot_1779708243.png b/PetFolio-UI-Screens/Screenshot_1779708243.png
new file mode 100644
index 0000000..69e8e48
Binary files /dev/null and b/PetFolio-UI-Screens/Screenshot_1779708243.png differ
diff --git a/PetFolio-UI-Screens/Screenshot_1779708247.png b/PetFolio-UI-Screens/Screenshot_1779708247.png
new file mode 100644
index 0000000..e5450fc
Binary files /dev/null and b/PetFolio-UI-Screens/Screenshot_1779708247.png differ
diff --git a/PetFolio-UI-Screens/Screenshot_1779708249.png b/PetFolio-UI-Screens/Screenshot_1779708249.png
new file mode 100644
index 0000000..4386629
Binary files /dev/null and b/PetFolio-UI-Screens/Screenshot_1779708249.png differ
diff --git a/PetFolio-UI-Screens/Screenshot_1779708250.png b/PetFolio-UI-Screens/Screenshot_1779708250.png
new file mode 100644
index 0000000..c70c8a7
Binary files /dev/null and b/PetFolio-UI-Screens/Screenshot_1779708250.png differ
diff --git a/PetFolio-UI-Screens/Screenshot_1779708253.png b/PetFolio-UI-Screens/Screenshot_1779708253.png
new file mode 100644
index 0000000..6237e32
Binary files /dev/null and b/PetFolio-UI-Screens/Screenshot_1779708253.png differ
diff --git a/PetFolio-UI-Screens/Screenshot_1779708265.png b/PetFolio-UI-Screens/Screenshot_1779708265.png
new file mode 100644
index 0000000..a6e74ee
Binary files /dev/null and b/PetFolio-UI-Screens/Screenshot_1779708265.png differ
diff --git a/PetFolio-UI-Screens/Screenshot_1779708268.png b/PetFolio-UI-Screens/Screenshot_1779708268.png
new file mode 100644
index 0000000..af86bc5
Binary files /dev/null and b/PetFolio-UI-Screens/Screenshot_1779708268.png differ
diff --git a/PetFolio-UI-Screens/Screenshot_1779708272.png b/PetFolio-UI-Screens/Screenshot_1779708272.png
new file mode 100644
index 0000000..290f06b
Binary files /dev/null and b/PetFolio-UI-Screens/Screenshot_1779708272.png differ
diff --git a/PetFolio-UI-Screens/Screenshot_1779708280.png b/PetFolio-UI-Screens/Screenshot_1779708280.png
new file mode 100644
index 0000000..7b59065
Binary files /dev/null and b/PetFolio-UI-Screens/Screenshot_1779708280.png differ
diff --git a/PetFolio-UI-Screens/Screenshot_1779708283.png b/PetFolio-UI-Screens/Screenshot_1779708283.png
new file mode 100644
index 0000000..e97170f
Binary files /dev/null and b/PetFolio-UI-Screens/Screenshot_1779708283.png differ
diff --git a/PetFolio-UI-Screens/Screenshot_1779708289.png b/PetFolio-UI-Screens/Screenshot_1779708289.png
new file mode 100644
index 0000000..7290a34
Binary files /dev/null and b/PetFolio-UI-Screens/Screenshot_1779708289.png differ
diff --git a/PetFolio-UI-Screens/Screenshot_1779708296.png b/PetFolio-UI-Screens/Screenshot_1779708296.png
new file mode 100644
index 0000000..510cc3e
Binary files /dev/null and b/PetFolio-UI-Screens/Screenshot_1779708296.png differ
diff --git a/PetFolio-UI-Screens/Screenshot_1779708298.png b/PetFolio-UI-Screens/Screenshot_1779708298.png
new file mode 100644
index 0000000..206ce97
Binary files /dev/null and b/PetFolio-UI-Screens/Screenshot_1779708298.png differ
diff --git a/PetFolio-UI-Screens/Screenshot_1779708311.png b/PetFolio-UI-Screens/Screenshot_1779708311.png
new file mode 100644
index 0000000..7a15f85
Binary files /dev/null and b/PetFolio-UI-Screens/Screenshot_1779708311.png differ
diff --git a/PetFolio-UI-Screens/Screenshot_1779708328.png b/PetFolio-UI-Screens/Screenshot_1779708328.png
new file mode 100644
index 0000000..2b9b5a2
Binary files /dev/null and b/PetFolio-UI-Screens/Screenshot_1779708328.png differ
diff --git a/PetFolio-UI-Screens/Screenshot_1779708341.png b/PetFolio-UI-Screens/Screenshot_1779708341.png
new file mode 100644
index 0000000..b6f6b73
Binary files /dev/null and b/PetFolio-UI-Screens/Screenshot_1779708341.png differ
diff --git a/PetFolio-UI-Screens/Screenshot_1779708343.png b/PetFolio-UI-Screens/Screenshot_1779708343.png
new file mode 100644
index 0000000..719982d
Binary files /dev/null and b/PetFolio-UI-Screens/Screenshot_1779708343.png differ
diff --git a/PetFolio-UI-Screens/Screenshot_1779708347.png b/PetFolio-UI-Screens/Screenshot_1779708347.png
new file mode 100644
index 0000000..65bf165
Binary files /dev/null and b/PetFolio-UI-Screens/Screenshot_1779708347.png differ
diff --git a/PetFolio-UI-Screens/Screenshot_1779708350.png b/PetFolio-UI-Screens/Screenshot_1779708350.png
new file mode 100644
index 0000000..bf71a72
Binary files /dev/null and b/PetFolio-UI-Screens/Screenshot_1779708350.png differ
diff --git a/PetFolio-UI-Screens/Screenshot_1779708353.png b/PetFolio-UI-Screens/Screenshot_1779708353.png
new file mode 100644
index 0000000..4a78f5d
Binary files /dev/null and b/PetFolio-UI-Screens/Screenshot_1779708353.png differ
diff --git a/PetFolio-UI-Screens/Screenshot_1779708359.png b/PetFolio-UI-Screens/Screenshot_1779708359.png
new file mode 100644
index 0000000..a3ad2eb
Binary files /dev/null and b/PetFolio-UI-Screens/Screenshot_1779708359.png differ
diff --git a/PetFolio-UI-Screens/Screenshot_1779708362.png b/PetFolio-UI-Screens/Screenshot_1779708362.png
new file mode 100644
index 0000000..0251e21
Binary files /dev/null and b/PetFolio-UI-Screens/Screenshot_1779708362.png differ
diff --git a/PetFolio-UI-Screens/Screenshot_1779708369.png b/PetFolio-UI-Screens/Screenshot_1779708369.png
new file mode 100644
index 0000000..d5856ce
Binary files /dev/null and b/PetFolio-UI-Screens/Screenshot_1779708369.png differ
diff --git a/PetFolio-UI-Screens/Screenshot_1779708375.png b/PetFolio-UI-Screens/Screenshot_1779708375.png
new file mode 100644
index 0000000..fd1199a
Binary files /dev/null and b/PetFolio-UI-Screens/Screenshot_1779708375.png differ
diff --git a/PetFolio-UI-Screens/Screenshot_1779708382.png b/PetFolio-UI-Screens/Screenshot_1779708382.png
new file mode 100644
index 0000000..7e1bbf4
Binary files /dev/null and b/PetFolio-UI-Screens/Screenshot_1779708382.png differ
diff --git a/PetFolio-UI-Screens/Screenshot_1779708406.png b/PetFolio-UI-Screens/Screenshot_1779708406.png
new file mode 100644
index 0000000..cc757d8
Binary files /dev/null and b/PetFolio-UI-Screens/Screenshot_1779708406.png differ
diff --git a/PetFolio-UI-Screens/Screenshot_1779708410.png b/PetFolio-UI-Screens/Screenshot_1779708410.png
new file mode 100644
index 0000000..afcbde2
Binary files /dev/null and b/PetFolio-UI-Screens/Screenshot_1779708410.png differ
diff --git a/PetFolio-UI-Screens/Screenshot_1779708417.png b/PetFolio-UI-Screens/Screenshot_1779708417.png
new file mode 100644
index 0000000..d6dddf5
Binary files /dev/null and b/PetFolio-UI-Screens/Screenshot_1779708417.png differ
diff --git a/PetFolio-UI-Screens/Screenshot_1779708420.png b/PetFolio-UI-Screens/Screenshot_1779708420.png
new file mode 100644
index 0000000..faad355
Binary files /dev/null and b/PetFolio-UI-Screens/Screenshot_1779708420.png differ
diff --git a/PetFolio-UI-Screens/Screenshot_1779708423.png b/PetFolio-UI-Screens/Screenshot_1779708423.png
new file mode 100644
index 0000000..460d355
Binary files /dev/null and b/PetFolio-UI-Screens/Screenshot_1779708423.png differ
diff --git a/PetFolio-UI-Screens/Screenshot_1779708426.png b/PetFolio-UI-Screens/Screenshot_1779708426.png
new file mode 100644
index 0000000..ea06bfc
Binary files /dev/null and b/PetFolio-UI-Screens/Screenshot_1779708426.png differ
diff --git a/PetFolio-UI-Screens/Screenshot_1779708442.png b/PetFolio-UI-Screens/Screenshot_1779708442.png
new file mode 100644
index 0000000..d44340e
Binary files /dev/null and b/PetFolio-UI-Screens/Screenshot_1779708442.png differ
diff --git a/PetFolio-UI-Screens/Screenshot_1779708466.png b/PetFolio-UI-Screens/Screenshot_1779708466.png
new file mode 100644
index 0000000..cbb9527
Binary files /dev/null and b/PetFolio-UI-Screens/Screenshot_1779708466.png differ
diff --git a/PetFolio-UI-Screens/Screenshot_1779708470.png b/PetFolio-UI-Screens/Screenshot_1779708470.png
new file mode 100644
index 0000000..cf29204
Binary files /dev/null and b/PetFolio-UI-Screens/Screenshot_1779708470.png differ
diff --git a/PetFolio-UI-Screens/Screenshot_1779708473.png b/PetFolio-UI-Screens/Screenshot_1779708473.png
new file mode 100644
index 0000000..15399ce
Binary files /dev/null and b/PetFolio-UI-Screens/Screenshot_1779708473.png differ
diff --git a/PetFolio-UI-Screens/Screenshot_1779708479.png b/PetFolio-UI-Screens/Screenshot_1779708479.png
new file mode 100644
index 0000000..db254b8
Binary files /dev/null and b/PetFolio-UI-Screens/Screenshot_1779708479.png differ
diff --git a/PetFolio-UI-Screens/Screenshot_1779708482.png b/PetFolio-UI-Screens/Screenshot_1779708482.png
new file mode 100644
index 0000000..105261a
Binary files /dev/null and b/PetFolio-UI-Screens/Screenshot_1779708482.png differ
diff --git a/PetFolio-UI-Screens/Screenshot_1779708491.png b/PetFolio-UI-Screens/Screenshot_1779708491.png
new file mode 100644
index 0000000..96399df
Binary files /dev/null and b/PetFolio-UI-Screens/Screenshot_1779708491.png differ
diff --git a/PetFolio-UI-Screens/Screenshot_1779708499.png b/PetFolio-UI-Screens/Screenshot_1779708499.png
new file mode 100644
index 0000000..6c28cdc
Binary files /dev/null and b/PetFolio-UI-Screens/Screenshot_1779708499.png differ
diff --git a/PetFolio-UI-Screens/Screenshot_1779708505.png b/PetFolio-UI-Screens/Screenshot_1779708505.png
new file mode 100644
index 0000000..96e7462
Binary files /dev/null and b/PetFolio-UI-Screens/Screenshot_1779708505.png differ
diff --git a/PetFolio-UI-Screens/Screenshot_1779708510.png b/PetFolio-UI-Screens/Screenshot_1779708510.png
new file mode 100644
index 0000000..374aad8
Binary files /dev/null and b/PetFolio-UI-Screens/Screenshot_1779708510.png differ
diff --git a/PetFolio-UI-Screens/Screenshot_1779708515.png b/PetFolio-UI-Screens/Screenshot_1779708515.png
new file mode 100644
index 0000000..0c8087f
Binary files /dev/null and b/PetFolio-UI-Screens/Screenshot_1779708515.png differ
diff --git a/PetFolio-UI-Screens/Screenshot_1779708520.png b/PetFolio-UI-Screens/Screenshot_1779708520.png
new file mode 100644
index 0000000..f28eb72
Binary files /dev/null and b/PetFolio-UI-Screens/Screenshot_1779708520.png differ
diff --git a/PetFolio-UI-Screens/Screenshot_1779708523.png b/PetFolio-UI-Screens/Screenshot_1779708523.png
new file mode 100644
index 0000000..adf6d4a
Binary files /dev/null and b/PetFolio-UI-Screens/Screenshot_1779708523.png differ
diff --git a/PetFolio-UI-Screens/Screenshot_1779708527.png b/PetFolio-UI-Screens/Screenshot_1779708527.png
new file mode 100644
index 0000000..cc9956d
Binary files /dev/null and b/PetFolio-UI-Screens/Screenshot_1779708527.png differ
diff --git a/lib/core/router.dart b/lib/core/router.dart
index 152eed2..a7b4d38 100644
--- a/lib/core/router.dart
+++ b/lib/core/router.dart
@@ -40,10 +40,12 @@ import '../features/pet_profile/presentation/screens/onboarding_screen.dart';
import '../features/pet_profile/presentation/screens/pet_profile_screen.dart';
import '../features/social/data/models/feed_post.dart';
import '../features/social/presentation/screens/create_post_screen.dart';
+import '../features/social/presentation/screens/create_story_screen.dart';
import '../features/social/presentation/screens/notifications_screen.dart';
import '../features/social/presentation/screens/post_detail_screen.dart';
import '../features/social/presentation/screens/social_profile_screen.dart';
import '../features/social/presentation/screens/social_screen.dart';
+import '../features/social/presentation/screens/story_viewer_screen.dart';
// ─────────────────────────────────────────────────────────────────────────────
// Router provider — must be consumed with ref.watch in the app widget.
@@ -240,9 +242,14 @@ final routerProvider = Provider((ref) {
),
GoRoute(
parentNavigatorKey: _rootNavigatorKey,
- path: '/social/create',
+ path: '/social/create-post',
builder: (context, state) => const CreatePostScreen(),
),
+ GoRoute(
+ parentNavigatorKey: _rootNavigatorKey,
+ path: '/social/create-story',
+ builder: (context, state) => const CreateStoryScreen(),
+ ),
GoRoute(
parentNavigatorKey: _rootNavigatorKey,
path: '/social/post/:postId',
@@ -264,6 +271,14 @@ final routerProvider = Provider((ref) {
petId: state.pathParameters['petId']!,
),
),
+ GoRoute(
+ parentNavigatorKey: _rootNavigatorKey,
+ path: '/social/stories',
+ builder: (context, state) {
+ final petId = state.uri.queryParameters['petId'] ?? '';
+ return StoryViewerScreen(initialPetId: petId);
+ },
+ ),
GoRoute(
parentNavigatorKey: _rootNavigatorKey,
path: '/matching/inbox',
diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart
index d3f959a..c4b7e5f 100644
--- a/lib/core/theme/app_colors.dart
+++ b/lib/core/theme/app_colors.dart
@@ -46,25 +46,25 @@ abstract final class AppColors {
// ── §2.3 Neutrals — cool-warm hybrid ─────────────────────────────────────
static const ink950 = Color(0xFF0B1220); // Primary text (light)
- static const ink950D = Color(0xFFF4F6FB); // Primary text (dark)
+ static const ink950D = Color(0xFFF8FAFC); // Primary text (dark)
static const ink700 = Color(0xFF2A3447); // Body text (light)
- static const ink700D = Color(0xFFC7CEDB); // Body text (dark)
+ static const ink700D = Color(0xFFCBD5E1); // Body text (dark)
static const ink500 = Color(0xFF5C657A); // Secondary/caption (light)
- static const ink500D = Color(0xFF8A93A6); // Secondary/caption (dark)
+ static const ink500D = Color(0xFF94A3B8); // Secondary/caption (dark)
static const ink300 = Color(0xFFA3ABBC); // Placeholder (light)
- static const ink300D = Color(0xFF5C657A); // Placeholder (dark)
+ static const ink300D = Color(0xFF64748B); // Placeholder (dark)
static const line200 = Color(0xFFE4E7EF); // Hairline dividers (light)
- static const line200D = Color(0xFF1F2738); // Hairline dividers (dark)
+ static const line200D = Color(0xFF334155); // Hairline dividers (dark)
static const line100 = Color(0xFFEEF1F7); // Subtle dividers (light)
- static const line100D = Color(0xFF172033); // Subtle dividers (dark)
+ static const line100D = Color(0xFF1E293B); // Subtle dividers (dark)
static const surface0 = Color(0xFFFFFFFF); // Card / sheet (light)
- static const surface0D = Color(0xFF0A0F1C); // Card / sheet (dark)
+ static const surface0D = Color(0xFF1E293B); // Card / sheet (dark)
static const surface1 = Color(0xFFFAFBFD); // App background (light)
- static const surface1D = Color(0xFF0F1525); // App background (dark)
+ static const surface1D = Color(0xFF0F172A); // App background (dark)
static const surface2 = Color(0xFFF2F4F9); // Inset wells (light)
- static const surface2D = Color(0xFF141B2D); // Inset wells (dark)
+ static const surface2D = Color(0xFF020617); // Inset wells (dark)
// ── §2.4 Semantic ────────────────────────────────────────────────────────
static const success = Color(0xFF1F8A5B);
diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart
index 6be4c49..a04e9d6 100644
--- a/lib/core/theme/app_theme.dart
+++ b/lib/core/theme/app_theme.dart
@@ -3,6 +3,8 @@ import 'package:google_fonts/google_fonts.dart';
import 'app_colors.dart';
+
+
// ─────────────────────────────────────────────────────────────────────────────
// PetfolioThemeExtension
// ─────────────────────────────────────────────────────────────────────────────
diff --git a/lib/core/theme/theme.dart b/lib/core/theme/theme.dart
index 1e720be..844661b 100644
--- a/lib/core/theme/theme.dart
+++ b/lib/core/theme/theme.dart
@@ -1,2 +1,3 @@
export 'app_colors.dart';
export 'app_theme.dart';
+export 'theme_notifier.dart';
diff --git a/lib/core/theme/theme_notifier.dart b/lib/core/theme/theme_notifier.dart
new file mode 100644
index 0000000..9620547
--- /dev/null
+++ b/lib/core/theme/theme_notifier.dart
@@ -0,0 +1,37 @@
+import 'package:flutter/material.dart';
+import 'package:riverpod_annotation/riverpod_annotation.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+part 'theme_notifier.g.dart';
+
+@riverpod
+class ThemeNotifier extends _$ThemeNotifier {
+ static const _themeKey = 'theme_mode';
+
+ @override
+ ThemeMode build() {
+ _loadTheme();
+ return ThemeMode.system;
+ }
+
+ Future _loadTheme() async {
+ final prefs = await SharedPreferences.getInstance();
+ final index = prefs.getInt(_themeKey);
+ if (index != null && index >= 0 && index < ThemeMode.values.length) {
+ state = ThemeMode.values[index];
+ }
+ }
+
+ Future toggleTheme() async {
+ final newMode = state == ThemeMode.dark ? ThemeMode.light : ThemeMode.dark;
+ state = newMode;
+ final prefs = await SharedPreferences.getInstance();
+ await prefs.setInt(_themeKey, newMode.index);
+ }
+
+ Future setThemeMode(ThemeMode mode) async {
+ state = mode;
+ final prefs = await SharedPreferences.getInstance();
+ await prefs.setInt(_themeKey, mode.index);
+ }
+}
diff --git a/lib/core/theme/theme_notifier.g.dart b/lib/core/theme/theme_notifier.g.dart
new file mode 100644
index 0000000..b5be950
--- /dev/null
+++ b/lib/core/theme/theme_notifier.g.dart
@@ -0,0 +1,62 @@
+// GENERATED CODE - DO NOT MODIFY BY HAND
+
+part of 'theme_notifier.dart';
+
+// **************************************************************************
+// RiverpodGenerator
+// **************************************************************************
+
+// GENERATED CODE - DO NOT MODIFY BY HAND
+// ignore_for_file: type=lint, type=warning
+
+@ProviderFor(ThemeNotifier)
+final themeProvider = ThemeNotifierProvider._();
+
+final class ThemeNotifierProvider
+ extends $NotifierProvider {
+ ThemeNotifierProvider._()
+ : super(
+ from: null,
+ argument: null,
+ retry: null,
+ name: r'themeProvider',
+ isAutoDispose: true,
+ dependencies: null,
+ $allTransitiveDependencies: null,
+ );
+
+ @override
+ String debugGetCreateSourceHash() => _$themeNotifierHash();
+
+ @$internal
+ @override
+ ThemeNotifier create() => ThemeNotifier();
+
+ /// {@macro riverpod.override_with_value}
+ Override overrideWithValue(ThemeMode value) {
+ return $ProviderOverride(
+ origin: this,
+ providerOverride: $SyncValueProvider(value),
+ );
+ }
+}
+
+String _$themeNotifierHash() => r'bf582de271e6d56a27547830a4e3b45ba3fc716f';
+
+abstract class _$ThemeNotifier extends $Notifier {
+ ThemeMode build();
+ @$mustCallSuper
+ @override
+ void runBuild() {
+ final ref = this.ref as $Ref;
+ final element =
+ ref.element
+ as $ClassProviderElement<
+ AnyNotifier,
+ ThemeMode,
+ Object?,
+ Object?
+ >;
+ element.handleCreate(ref, build);
+ }
+}
diff --git a/lib/core/widgets/app_header.dart b/lib/core/widgets/app_header.dart
index 72948d8..e450156 100644
--- a/lib/core/widgets/app_header.dart
+++ b/lib/core/widgets/app_header.dart
@@ -154,7 +154,9 @@ class _PetSwitcherTrigger extends StatelessWidget {
final cs = Theme.of(context).colorScheme;
final species = pet?.speciesEnum ?? PetSpecies.dog;
- final avatarTap = pet != null ? () => context.go('/home') : null;
+ final avatarTap = pet != null
+ ? () => context.push('/social/profile/${pet!.id}')
+ : null;
return Row(
children: [
diff --git a/lib/features/care/presentation/controllers/care_dashboard_controller.g.dart b/lib/features/care/presentation/controllers/care_dashboard_controller.g.dart
index 75c0ea7..a877bc9 100644
--- a/lib/features/care/presentation/controllers/care_dashboard_controller.g.dart
+++ b/lib/features/care/presentation/controllers/care_dashboard_controller.g.dart
@@ -41,7 +41,7 @@ final class CareDashboardProvider
}
}
-String _$careDashboardHash() => r'eef1ae822b69ab2064bbc6b052fc669c95880fd4';
+String _$careDashboardHash() => r'975a8e9cc30d30525e321ea162cea957639db83e';
abstract class _$CareDashboard extends $Notifier {
DailyRoutineState build();
diff --git a/lib/features/care/presentation/screens/care_screen.dart b/lib/features/care/presentation/screens/care_screen.dart
index 29500e2..e36ba80 100644
--- a/lib/features/care/presentation/screens/care_screen.dart
+++ b/lib/features/care/presentation/screens/care_screen.dart
@@ -33,7 +33,6 @@ class CareScreen extends ConsumerStatefulWidget {
}
class _CareScreenState extends ConsumerState {
- bool _outdoor = false;
bool _onboardingSuccessHandled = false;
bool _isGeneratingRoutine = false;
@@ -105,6 +104,7 @@ class _CareScreenState extends ConsumerState {
final pt = Theme.of(context).extension()!;
final cs = Theme.of(context).colorScheme;
final activePet = ref.watch(activePetControllerProvider);
+ final themeMode = ref.watch(themeProvider);
final petsAsync = ref.watch(petListProvider);
if (activePet == null) {
@@ -155,7 +155,7 @@ class _CareScreenState extends ConsumerState {
);
return Scaffold(
- backgroundColor: _outdoor ? cs.surface : pt.surface1,
+ backgroundColor: pt.surface1,
floatingActionButton: FloatingActionButton(
key: const ValueKey('care_fab_add_task'),
onPressed: openAddSheet,
@@ -173,13 +173,15 @@ class _CareScreenState extends ConsumerState {
: null,
actions: [
AppHeaderAction(
- iconKey: const ValueKey('care_action_outdoor'),
- icon: _outdoor
- ? Icons.wb_sunny
- : Icons.wb_sunny_outlined,
- tooltip: _outdoor ? 'Indoor mode' : 'Outdoor mode',
- filled: _outdoor,
- onTap: () => setState(() => _outdoor = !_outdoor),
+ iconKey: const ValueKey('care_action_theme'),
+ icon: themeMode == ThemeMode.dark
+ ? Icons.light_mode_outlined
+ : Icons.dark_mode_outlined,
+ tooltip: themeMode == ThemeMode.dark
+ ? 'Switch to light theme'
+ : 'Switch to dark theme',
+ onTap: () =>
+ ref.read(themeProvider.notifier).toggleTheme(),
),
],
),
@@ -192,7 +194,6 @@ class _CareScreenState extends ConsumerState {
children: [
_StreakBanner(
species: species,
- outdoor: _outdoor,
),
const SizedBox(height: 24),
DecoratedBox(
@@ -228,7 +229,7 @@ class _CareScreenState extends ConsumerState {
fontSize: 12,
fontWeight: FontWeight.w700,
letterSpacing: 0.08 * 12,
- color: _outdoor ? AppColors.ink700 : pt.ink500,
+ color: pt.ink500,
),
),
const Spacer(),
@@ -394,11 +395,9 @@ class _AiRoutineBanner extends StatelessWidget {
class _StreakBanner extends ConsumerWidget {
const _StreakBanner({
required this.species,
- required this.outdoor,
});
final PetSpecies species;
- final bool outdoor;
static const _dayLetters = ['M', 'T', 'W', 'T', 'F', 'S', 'S'];
static const _monthShort = [
@@ -454,16 +453,14 @@ class _StreakBanner extends ConsumerWidget {
colors: [accent, darkAccent],
),
borderRadius: BorderRadius.circular(24),
- boxShadow: outdoor
- ? null
- : [
- BoxShadow(
- color: accent.withAlpha(136),
- blurRadius: 36,
- offset: const Offset(0, 18),
- spreadRadius: -16,
- ),
- ],
+ boxShadow: [
+ BoxShadow(
+ color: accent.withAlpha(136),
+ blurRadius: 36,
+ offset: const Offset(0, 18),
+ spreadRadius: -16,
+ ),
+ ],
),
child: Stack(
children: [
diff --git a/lib/features/marketplace/presentation/controllers/my_shop_controller.g.dart b/lib/features/marketplace/presentation/controllers/my_shop_controller.g.dart
index 0762278..71ed3fc 100644
--- a/lib/features/marketplace/presentation/controllers/my_shop_controller.g.dart
+++ b/lib/features/marketplace/presentation/controllers/my_shop_controller.g.dart
@@ -32,7 +32,7 @@ final class MyShopProvider extends $AsyncNotifierProvider {
MyShop create() => MyShop();
}
-String _$myShopHash() => r'aafbe492b2e171dab745289dc0e2f29964b93814';
+String _$myShopHash() => r'fac92bbca66b9a3d53a8fcc0d58b0f579d07609b';
abstract class _$MyShop extends $AsyncNotifier {
FutureOr build();
diff --git a/lib/features/pet_profile/presentation/screens/pet_profile_screen.dart b/lib/features/pet_profile/presentation/screens/pet_profile_screen.dart
index 9123176..a317118 100644
--- a/lib/features/pet_profile/presentation/screens/pet_profile_screen.dart
+++ b/lib/features/pet_profile/presentation/screens/pet_profile_screen.dart
@@ -35,6 +35,7 @@ class PetProfileScreen extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final activePet = ref.watch(activePetControllerProvider);
final petsAsync = ref.watch(petListProvider);
+ final themeMode = ref.watch(themeProvider);
final pt = Theme.of(context).extension()!;
return Scaffold(
@@ -50,10 +51,14 @@ class PetProfileScreen extends ConsumerWidget {
onOpenSwitcher: () => PetSwitcherSheet.show(context),
actions: [
AppHeaderAction(
- iconKey: const ValueKey('home_action_outdoor'),
- icon: Icons.wb_sunny_outlined,
- tooltip: 'Coming soon',
- onTap: () {},
+ iconKey: const ValueKey('home_action_theme'),
+ icon: themeMode == ThemeMode.dark
+ ? Icons.light_mode_outlined
+ : Icons.dark_mode_outlined,
+ tooltip: themeMode == ThemeMode.dark
+ ? 'Switch to light theme'
+ : 'Switch to dark theme',
+ onTap: () => ref.read(themeProvider.notifier).toggleTheme(),
),
AppHeaderAction(
iconKey: const ValueKey('home_action_notifications'),
@@ -138,15 +143,7 @@ class PetProfileScreen extends ConsumerWidget {
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 0),
- child: PrimaryPillButton(
- isFullWidth: true,
- leadingIcon:
- const Icon(Icons.dynamic_feed_rounded),
- label: 'View Social Profile',
- onPressed: () => context.push(
- '/social/profile/${activePet.id}',
- ),
- ),
+ child: _SocialProfileCard(pet: activePet, pt: pt),
),
),
SliverToBoxAdapter(
@@ -296,6 +293,76 @@ class _SellerDashboardCard extends ConsumerWidget {
}
}
+class _SocialProfileCard extends StatelessWidget {
+ const _SocialProfileCard({required this.pet, required this.pt});
+
+ final Pet pet;
+ final PetfolioThemeExtension pt;
+
+ @override
+ Widget build(BuildContext context) {
+ final cs = Theme.of(context).colorScheme;
+
+ return Semantics(
+ button: true,
+ label: 'Social Profile. View posts, likes, and activity for ${pet.name}',
+ child: GestureDetector(
+ onTap: () => context.push('/social/profile/${pet.id}'),
+ child: Container(
+ padding: const EdgeInsets.fromLTRB(14, 14, 14, 14),
+ decoration: BoxDecoration(
+ color: cs.surface,
+ borderRadius: BorderRadius.circular(18),
+ border: Border.all(color: pt.line200, width: 0.5),
+ boxShadow: const [
+ BoxShadow(
+ color: AppColors.shadowE1L,
+ blurRadius: 2,
+ offset: Offset(0, 1),
+ ),
+ ],
+ ),
+ child: Row(
+ children: [
+ Container(
+ width: 44,
+ height: 44,
+ decoration: BoxDecoration(
+ color: AppColors.mulberry500.withAlpha(30),
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: const Icon(Icons.dynamic_feed_rounded,
+ color: AppColors.mulberry500, size: 22),
+ ),
+ const SizedBox(width: 14),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ 'Social Profile',
+ style: Theme.of(context).textTheme.titleSmall!.copyWith(
+ fontFamily: 'Sora',
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ const SizedBox(height: 2),
+ Text(
+ 'Posts, likes, and activity for ${pet.name}',
+ style: TextStyle(fontSize: 13, color: pt.ink500),
+ ),
+ ],
+ ),
+ ),
+ Icon(Icons.chevron_right_rounded, color: pt.ink300, size: 22),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
+
class _PetStatsRow extends StatelessWidget {
const _PetStatsRow({required this.pet});
@@ -481,7 +548,6 @@ class _ProfileOverviewTab extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final recordsAsync = ref.watch(healthVaultControllerProvider);
- final cs = Theme.of(context).colorScheme;
return Builder(
builder: (context) {
@@ -534,56 +600,6 @@ class _ProfileOverviewTab extends ConsumerWidget {
];
},
),
- const SizedBox(height: 20),
- _SectionLabel(label: 'Social'),
- const SizedBox(height: 12),
- GestureDetector(
- onTap: () => context.push('/social/profile/${pet.id}'),
- child: Container(
- padding: const EdgeInsets.fromLTRB(14, 14, 14, 14),
- decoration: BoxDecoration(
- color: cs.surface,
- borderRadius: BorderRadius.circular(18),
- border: Border.all(color: pt.line200, width: 0.5),
- ),
- child: Row(
- children: [
- Container(
- width: 44,
- height: 44,
- decoration: BoxDecoration(
- color: AppColors.mulberry500.withAlpha(30),
- borderRadius: BorderRadius.circular(12),
- ),
- child: const Icon(Icons.dynamic_feed_rounded,
- color: AppColors.mulberry500, size: 22),
- ),
- const SizedBox(width: 14),
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- const Text(
- 'View Social Profile',
- style: TextStyle(
- fontFamily: 'Sora',
- fontWeight: FontWeight.w600,
- fontSize: 15,
- ),
- ),
- const SizedBox(height: 2),
- Text(
- 'Posts, likes, and activity for ${pet.name}',
- style: TextStyle(fontSize: 13, color: pt.ink500),
- ),
- ],
- ),
- ),
- Icon(Icons.chevron_right_rounded, color: pt.ink300, size: 22),
- ],
- ),
- ),
- ),
]),
),
),
diff --git a/lib/features/social/data/models/comment.dart b/lib/features/social/data/models/comment.dart
index a955755..beb9ab2 100644
--- a/lib/features/social/data/models/comment.dart
+++ b/lib/features/social/data/models/comment.dart
@@ -13,6 +13,9 @@ class Comment {
required this.createdAt,
required this.isOwnComment,
this.avatarUrl,
+ this.parentId,
+ this.likeCount = 0,
+ this.isLiked = false,
});
final String id;
@@ -33,6 +36,10 @@ class Comment {
/// Used by the UI to show a delete affordance.
final bool isOwnComment;
+ final String? parentId;
+ final int likeCount;
+ final bool isLiked;
+
/// Human-readable relative time string (e.g. "2h ago").
String get timeAgo {
final diff = DateTime.now().difference(createdAt);
@@ -42,7 +49,43 @@ class Comment {
return '${diff.inDays}d ago';
}
- factory Comment.fromJson(Map json, {required String activePetId}) {
+ Comment copyWithLike({required bool liked}) => Comment(
+ id: id,
+ postId: postId,
+ petId: petId,
+ handle: handle,
+ petName: petName,
+ content: content,
+ createdAt: createdAt,
+ isOwnComment: isOwnComment,
+ avatarUrl: avatarUrl,
+ parentId: parentId,
+ likeCount: liked ? likeCount + 1 : (likeCount > 0 ? likeCount - 1 : 0),
+ isLiked: liked,
+ );
+
+ /// Returns a copy of this comment with [newContent] replacing [content].
+ ///
+ /// Used by [CommentNotifier.edit] for optimistic UI updates.
+ Comment copyWithContent(String newContent) => Comment(
+ id: id,
+ postId: postId,
+ petId: petId,
+ handle: handle,
+ petName: petName,
+ content: newContent,
+ createdAt: createdAt,
+ isOwnComment: isOwnComment,
+ avatarUrl: avatarUrl,
+ parentId: parentId,
+ likeCount: likeCount,
+ isLiked: isLiked,
+ );
+
+ factory Comment.fromJson(Map json, {
+ required String activePetId,
+ bool isLiked = false,
+ }) {
final pet = json['pet'] as Map? ?? {};
return Comment(
id: json['id'] as String,
@@ -54,6 +97,9 @@ class Comment {
createdAt: DateTime.parse(json['created_at'] as String),
isOwnComment: json['pet_id'] as String == activePetId,
avatarUrl: pet['avatar_url'] as String?,
+ parentId: json['parent_id'] as String?,
+ likeCount: json['like_count'] as int? ?? 0,
+ isLiked: isLiked,
);
}
}
diff --git a/lib/features/social/data/models/story.dart b/lib/features/social/data/models/story.dart
new file mode 100644
index 0000000..894f90b
--- /dev/null
+++ b/lib/features/social/data/models/story.dart
@@ -0,0 +1,69 @@
+class Story {
+ final String id;
+ final String petId;
+ final String imageUrl;
+ final DateTime createdAt;
+ final List viewedByUsers;
+
+ // Joined fields for convenience in UI
+ final String petName;
+ final String? petAvatarUrl;
+ final String petSpecies;
+
+ const Story({
+ required this.id,
+ required this.petId,
+ required this.imageUrl,
+ required this.createdAt,
+ required this.viewedByUsers,
+ required this.petName,
+ this.petAvatarUrl,
+ required this.petSpecies,
+ });
+
+ factory Story.fromJson(Map json) {
+ final pet = (json['pet'] as Map?)?.cast() ?? const {};
+ return Story(
+ id: json['id'] as String,
+ petId: json['pet_id'] as String,
+ imageUrl: json['image_url'] as String,
+ createdAt: DateTime.parse(json['created_at'] as String),
+ viewedByUsers: (json['viewed_by_users'] as List?)?.cast() ?? const [],
+ petName: (pet['name'] as String?) ?? 'Unknown',
+ petAvatarUrl: pet['avatar_url'] as String?,
+ petSpecies: (pet['species'] as String?) ?? 'dog',
+ );
+ }
+
+ Map toJson() {
+ return {
+ 'id': id,
+ 'pet_id': petId,
+ 'image_url': imageUrl,
+ 'created_at': createdAt.toIso8601String(),
+ 'viewed_by_users': viewedByUsers,
+ };
+ }
+
+ Story copyWith({
+ String? id,
+ String? petId,
+ String? imageUrl,
+ DateTime? createdAt,
+ List? viewedByUsers,
+ String? petName,
+ String? petAvatarUrl,
+ String? petSpecies,
+ }) {
+ return Story(
+ id: id ?? this.id,
+ petId: petId ?? this.petId,
+ imageUrl: imageUrl ?? this.imageUrl,
+ createdAt: createdAt ?? this.createdAt,
+ viewedByUsers: viewedByUsers ?? this.viewedByUsers,
+ petName: petName ?? this.petName,
+ petAvatarUrl: petAvatarUrl ?? this.petAvatarUrl,
+ petSpecies: petSpecies ?? this.petSpecies,
+ );
+ }
+}
diff --git a/lib/features/social/data/repositories/comment_repository.dart b/lib/features/social/data/repositories/comment_repository.dart
index 150a118..4a72df0 100644
--- a/lib/features/social/data/repositories/comment_repository.dart
+++ b/lib/features/social/data/repositories/comment_repository.dart
@@ -40,18 +40,40 @@ class CommentRepository {
}) async {
final rows = await _client
.from('comments')
- .select('id, post_id, pet_id, content, created_at, pet:pets(name)')
+ .select('id, post_id, pet_id, content, created_at, parent_id, like_count, pet:pets(name, handle, avatar_url)')
.eq('post_id', postId)
.order('created_at', ascending: true);
- return (rows as List)
+ final comments = (rows as List).cast>();
+
+ var likedIds = const {};
+ if (activePetId.isNotEmpty && comments.isNotEmpty) {
+ final commentIds = comments.map((r) => r['id'] as String).toList();
+ likedIds = await _fetchLikedCommentIds(activePetId, commentIds);
+ }
+
+ return comments
.map((row) => Comment.fromJson(
- row as Map,
+ row,
activePetId: activePetId,
+ isLiked: likedIds.contains(row['id'] as String),
))
.toList();
}
+ Future> _fetchLikedCommentIds(
+ String petId,
+ List commentIds,
+ ) async {
+ if (commentIds.isEmpty) return const {};
+ final rows = await _client
+ .from('comment_likes')
+ .select('comment_id')
+ .eq('pet_id', petId)
+ .inFilter('comment_id', commentIds);
+ return {for (final r in (rows as List)) r['comment_id'] as String};
+ }
+
// ── Write ────────────────────────────────────────────────────────────────
/// Inserts a new comment. Returns the newly created [Comment].
@@ -62,6 +84,7 @@ class CommentRepository {
required String petId,
required String content,
required String activePetId,
+ String? parentId,
}) async {
final row = await _client
.from('comments')
@@ -70,17 +93,50 @@ class CommentRepository {
'author_id': _uid,
'pet_id': petId,
'content': content.trim(),
+ 'parent_id': ?parentId,
})
- .select('id, post_id, pet_id, content, created_at, pet:pets(name)')
+ .select('id, post_id, pet_id, content, created_at, parent_id, like_count, pet:pets(name, handle, avatar_url)')
.single();
return Comment.fromJson(row, activePetId: activePetId);
}
+ /// Likes or unlikes a comment.
+ Future toggleCommentLike({
+ required String commentId,
+ required String petId,
+ required bool liked,
+ }) async {
+ if (liked) {
+ await _client.from('comment_likes').upsert({
+ 'comment_id': commentId,
+ 'pet_id': petId,
+ 'user_id': _uid,
+ }, onConflict: 'comment_id, pet_id');
+ } else {
+ await _client
+ .from('comment_likes')
+ .delete()
+ .eq('comment_id', commentId)
+ .eq('pet_id', petId);
+ }
+ }
+
/// Deletes a comment by [commentId].
///
/// The RLS policy ensures only the comment's author can delete it.
Future deleteComment(String commentId) async {
await _client.from('comments').delete().eq('id', commentId);
}
+
+ /// Updates the [content] of an existing comment.
+ ///
+ /// The RLS policy (`author_id = auth.uid()`) enforces ownership server-side,
+ /// so no client-side guard is needed.
+ Future updateComment(String commentId, String newContent) async {
+ await _client
+ .from('comments')
+ .update({'content': newContent.trim()})
+ .eq('id', commentId);
+ }
}
diff --git a/lib/features/social/data/repositories/story_repository.dart b/lib/features/social/data/repositories/story_repository.dart
new file mode 100644
index 0000000..07aa7bf
--- /dev/null
+++ b/lib/features/social/data/repositories/story_repository.dart
@@ -0,0 +1,120 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:image_picker/image_picker.dart';
+import 'package:supabase_flutter/supabase_flutter.dart';
+
+import '../../../../core/errors/app_exception.dart';
+import '../models/story.dart';
+
+final storyRepositoryProvider = Provider(
+ (ref) => StoryRepository(Supabase.instance.client),
+);
+
+class StoryRepository {
+ final SupabaseClient _client;
+
+ StoryRepository(this._client);
+
+ String get _uid {
+ final id = _client.auth.currentUser?.id;
+ if (id == null) throw Exception('Not authenticated');
+ return id;
+ }
+
+ /// Fetches active stories from the last 24 hours.
+ Future> fetchActiveStories() async {
+ final cutoff = DateTime.now().subtract(const Duration(hours: 24)).toUtc().toIso8601String();
+
+ try {
+ final rows = await _client
+ .from('stories')
+ .select('''
+ id,
+ pet_id,
+ image_url,
+ created_at,
+ viewed_by_users,
+ pet:pets(id, name, avatar_url, species)
+ ''')
+ .gte('created_at', cutoff)
+ .order('created_at', ascending: false);
+
+ final list = (rows as List).cast>();
+ return list.map((r) => Story.fromJson(r)).toList();
+ } on PostgrestException catch (e) {
+ throw DatabaseException.fromPostgrest(e);
+ }
+ }
+
+ /// Marks a story as viewed by appending the current user's ID.
+ Future markStoryViewed(String storyId) async {
+ try {
+ await _client.rpc(
+ 'mark_story_viewed',
+ params: {'p_story_id': storyId},
+ );
+ } on PostgrestException catch (e) {
+ throw DatabaseException.fromPostgrest(e);
+ }
+ }
+
+ /// Creates a new story slide for a pet.
+ Future createStory({
+ required String petId,
+ required String imageUrl,
+ }) async {
+ try {
+ final row = await _client.from('stories').insert({
+ 'pet_id': petId,
+ 'image_url': imageUrl,
+ }).select('''
+ id,
+ pet_id,
+ image_url,
+ created_at,
+ viewed_by_users,
+ pet:pets(id, name, avatar_url, species)
+ ''').single();
+
+ return Story.fromJson(row);
+ } on PostgrestException catch (e) {
+ throw DatabaseException.fromPostgrest(e);
+ }
+ }
+
+ // ── Image Upload ──────────────────────────────────────────────────────────
+ static const _allowedExtensions = {'jpg', 'jpeg', 'png', 'webp', 'gif', 'heic'};
+ static const _maxImageBytes = 10 * 1024 * 1024; // 10 MB
+ static const _mimeTypes = {
+ 'jpg': 'image/jpeg',
+ 'jpeg': 'image/jpeg',
+ 'png': 'image/png',
+ 'webp': 'image/webp',
+ 'gif': 'image/gif',
+ 'heic': 'image/heic',
+ };
+
+ /// Uploads an image to the post-images bucket and returns the public URL.
+ Future uploadStoryImage(XFile file) async {
+ final ext = file.name.split('.').last.toLowerCase();
+ if (!_allowedExtensions.contains(ext)) {
+ throw const ValidationException(message: 'Unsupported image format. Use JPG, PNG, WebP, GIF, or HEIC.');
+ }
+
+ final bytes = await file.readAsBytes();
+ if (bytes.length > _maxImageBytes) {
+ throw const ValidationException(message: 'Image must be under 10 MB.');
+ }
+
+ final path = '$_uid/${DateTime.now().millisecondsSinceEpoch}.$ext';
+ try {
+ await _client.storage.from('post-images').uploadBinary(
+ path,
+ bytes,
+ fileOptions: FileOptions(contentType: _mimeTypes[ext] ?? 'image/$ext'),
+ );
+ } on StorageException catch (e) {
+ throw NetworkException(message: 'Image upload failed: ${e.message}');
+ }
+ return _client.storage.from('post-images').getPublicUrl(path);
+ }
+}
diff --git a/lib/features/social/presentation/controllers/comment_controller.dart b/lib/features/social/presentation/controllers/comment_controller.dart
index 5ef7258..216f97e 100644
--- a/lib/features/social/presentation/controllers/comment_controller.dart
+++ b/lib/features/social/presentation/controllers/comment_controller.dart
@@ -1,5 +1,6 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
+import '../../../../core/widgets/app_snack_bar.dart';
import '../../../pet_profile/presentation/controllers/active_pet_controller.dart';
import '../../data/models/comment.dart';
import '../../data/repositories/comment_repository.dart';
@@ -23,10 +24,7 @@ final commentListProvider =
/// Manages the comment list for a single post (identified by [arg] = postId).
///
-/// Optimistic UI pattern on [delete]:
-/// 1. Snapshot current state.
-/// 2. Remove comment locally — UI updates instantly.
-/// 3. Await Supabase delete — on error, restore snapshot.
+/// Optimistic UI pattern on [delete] and [toggleLike].
///
/// [add] is non-optimistic because we need the server-generated
/// timestamp and ID to display the comment correctly.
@@ -47,8 +45,12 @@ class CommentNotifier extends AsyncNotifier> {
// ── Public actions ────────────────────────────────────────────────────────
- /// Submits a new comment and appends it to the local list on success.
- Future add({required String petId, required String content}) async {
+ /// Submits a new comment (optionally a reply under [parentId]) and appends it to the local list on success.
+ Future add({
+ required String petId,
+ required String content,
+ String? parentId,
+ }) async {
if (content.trim().isEmpty) return;
final previousComments = state.value ?? [];
@@ -60,12 +62,16 @@ class CommentNotifier extends AsyncNotifier> {
petId: petId,
content: content,
activePetId: petId, // the new comment is always "own"
+ parentId: parentId,
);
state = AsyncData([...previousComments, newComment]);
// Optimistically update the parent feed's comment count
ref.read(socialControllerProvider(petId).notifier).incrementCommentCount(arg);
+
+ // Invalidate the post detail cache to refresh stats if loaded standalone
+ ref.invalidate(postDetailProvider(arg));
} catch (e) {
// Restore previous state so the list doesn't disappear on error.
state = AsyncData(previousComments);
@@ -91,9 +97,68 @@ class CommentNotifier extends AsyncNotifier> {
if (activePetId != null) {
ref.read(socialControllerProvider(activePetId).notifier).decrementCommentCount(arg);
}
- } catch (_) {
+
+ // Invalidate the post detail cache to refresh stats if loaded standalone
+ ref.invalidate(postDetailProvider(arg));
+ } catch (e) {
// 3. Rollback on failure.
state = AsyncData(prev);
+ AppSnackBar.showError(e);
+ }
+ }
+
+ /// Edits the content of a comment with an optimistic text swap.
+ ///
+ /// Rolls back to the previous text and shows a snackbar on failure.
+ /// No-op when [newContent] is blank.
+ Future edit(String commentId, String newContent) async {
+ final prev = state.value;
+ if (prev == null || newContent.trim().isEmpty) return;
+
+ // Optimistic content swap — no AsyncLoading so the list doesn't flicker.
+ final updated = prev
+ .map((c) => c.id == commentId ? c.copyWithContent(newContent.trim()) : c)
+ .toList();
+ state = AsyncData(updated);
+
+ try {
+ await _repo.updateComment(commentId, newContent);
+ } catch (e) {
+ // Rollback on failure.
+ state = AsyncData(prev);
+ AppSnackBar.showError(e);
+ }
+ }
+
+ /// Likes or unlikes a comment with optimistic feedback.
+ Future toggleLike(String commentId) async {
+ final prev = state.value;
+ if (prev == null) return;
+
+ final activePetId = ref.read(activePetControllerProvider)?.id;
+ if (activePetId == null || activePetId.isEmpty) return;
+
+ final idx = prev.indexWhere((c) => c.id == commentId);
+ if (idx == -1) return;
+
+ final comment = prev[idx];
+ final nowLiked = !comment.isLiked;
+
+ // Optimistic toggle
+ final updated = List.from(prev)
+ ..[idx] = comment.copyWithLike(liked: nowLiked);
+ state = AsyncData(updated);
+
+ try {
+ await _repo.toggleCommentLike(
+ commentId: commentId,
+ petId: activePetId,
+ liked: nowLiked,
+ );
+ } catch (e) {
+ // Rollback on failure
+ state = AsyncData(prev);
+ AppSnackBar.showError(e);
}
}
@@ -109,3 +174,4 @@ class CommentNotifier extends AsyncNotifier> {
}
}
}
+
diff --git a/lib/features/social/presentation/controllers/create_post_controller.dart b/lib/features/social/presentation/controllers/create_post_controller.dart
index 65a500d..2a080c0 100644
--- a/lib/features/social/presentation/controllers/create_post_controller.dart
+++ b/lib/features/social/presentation/controllers/create_post_controller.dart
@@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import '../../../../core/errors/app_exception.dart';
import '../../data/repositories/social_repository.dart';
+import 'story_controller.dart';
enum PostStep { idle, uploading, posting }
@@ -11,12 +12,14 @@ class CreatePostState {
this.caption = '',
this.step = PostStep.idle,
this.error,
+ this.isStory = false,
});
final XFile? image;
final String caption;
final PostStep step;
final String? error;
+ final bool isStory;
bool get isSubmitting => step != PostStep.idle;
@@ -27,12 +30,14 @@ class CreatePostState {
PostStep? step,
String? error,
bool clearError = false,
+ bool? isStory,
}) {
return CreatePostState(
image: clearImage ? null : (image ?? this.image),
caption: caption ?? this.caption,
step: step ?? this.step,
error: clearError ? null : (error ?? this.error),
+ isStory: isStory ?? this.isStory,
);
}
}
@@ -46,8 +51,34 @@ class CreatePostNotifier extends Notifier {
void setImage(XFile image) => state = state.copyWith(image: image, clearError: true);
void removeImage() => state = state.copyWith(clearImage: true);
void setCaption(String caption) => state = state.copyWith(caption: caption);
+ void setIsStory(bool isStory) => state = state.copyWith(isStory: isStory, clearError: true);
Future submit(String petId) async {
+ if (state.isStory) {
+ if (state.image == null) {
+ state = state.copyWith(error: 'Stories require an image.');
+ return false;
+ }
+
+ state = state.copyWith(step: PostStep.uploading, clearError: true);
+
+ try {
+ await ref.read(storiesProvider.notifier).addStory(
+ petId: petId,
+ imageFile: state.image!,
+ );
+
+ state = state.copyWith(step: PostStep.idle);
+ return true;
+ } on AppException catch (e) {
+ state = state.copyWith(step: PostStep.idle, error: e.message);
+ return false;
+ } catch (e) {
+ state = state.copyWith(step: PostStep.idle, error: e.toString());
+ return false;
+ }
+ }
+
if (state.image == null && state.caption.trim().isEmpty) {
state = state.copyWith(error: 'Please add an image or caption.');
return false;
diff --git a/lib/features/social/presentation/controllers/story_controller.dart b/lib/features/social/presentation/controllers/story_controller.dart
new file mode 100644
index 0000000..74ee1f5
--- /dev/null
+++ b/lib/features/social/presentation/controllers/story_controller.dart
@@ -0,0 +1,76 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:image_picker/image_picker.dart';
+import 'package:supabase_flutter/supabase_flutter.dart';
+
+import '../../../../core/widgets/app_snack_bar.dart';
+import '../../data/models/story.dart';
+import '../../data/repositories/story_repository.dart';
+
+final storiesProvider = AsyncNotifierProvider>(
+ StoriesController.new,
+);
+
+class StoriesController extends AsyncNotifier> {
+ @override
+ Future> build() async {
+ return ref.read(storyRepositoryProvider).fetchActiveStories();
+ }
+
+ StoryRepository get _repo => ref.read(storyRepositoryProvider);
+
+ /// Marks a story as viewed by the current user.
+ /// Update is executed optimistically on the UI list.
+ Future markStoryViewed(String storyId) async {
+ final current = state.value;
+ if (current == null) return;
+
+ final userId = Supabase.instance.client.auth.currentUser?.id;
+ if (userId == null) return;
+
+ final index = current.indexWhere((s) => s.id == storyId);
+ if (index == -1) return;
+
+ final story = current[index];
+ // If user has already viewed, do nothing.
+ if (story.viewedByUsers.contains(userId)) return;
+
+ // 1. Optimistic Update: Append the current user's ID to local state
+ final updatedList = List.from(current);
+ updatedList[index] = story.copyWith(
+ viewedByUsers: [...story.viewedByUsers, userId],
+ );
+ state = AsyncData(updatedList);
+
+ try {
+ // 2. Perform background write
+ await _repo.markStoryViewed(storyId);
+ } catch (e) {
+ // 3. Rollback on failure
+ state = AsyncData(current);
+ AppSnackBar.showError(e);
+ }
+ }
+
+ /// Uploads and posts a new story for a pet.
+ Future addStory({
+ required String petId,
+ required XFile imageFile,
+ }) async {
+ final current = state.value;
+ try {
+ // Upload image first
+ final imageUrl = await _repo.uploadStoryImage(imageFile);
+ // Create database record
+ final newStory = await _repo.createStory(petId: petId, imageUrl: imageUrl);
+
+ if (current != null) {
+ state = AsyncData([newStory, ...current]);
+ } else {
+ state = AsyncData([newStory]);
+ }
+ } catch (e) {
+ AppSnackBar.showError(e);
+ rethrow;
+ }
+ }
+}
diff --git a/lib/features/social/presentation/screens/create_post_screen.dart b/lib/features/social/presentation/screens/create_post_screen.dart
index 14b81bd..e81b2fe 100644
--- a/lib/features/social/presentation/screens/create_post_screen.dart
+++ b/lib/features/social/presentation/screens/create_post_screen.dart
@@ -27,6 +27,14 @@ class _CreatePostScreenState extends ConsumerState {
final _picker = ImagePicker();
static const _maxChars = 500;
+ @override
+ void initState() {
+ super.initState();
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ ref.read(createPostControllerProvider.notifier).setIsStory(false);
+ });
+ }
+
@override
void dispose() {
_captionController.dispose();
@@ -344,7 +352,7 @@ class _ImageWell extends StatelessWidget {
final cs = Theme.of(context).colorScheme;
return AspectRatio(
- aspectRatio: 4 / 3,
+ aspectRatio: 4 / 5,
child: Stack(
fit: StackFit.expand,
children: [
diff --git a/lib/features/social/presentation/screens/create_story_screen.dart b/lib/features/social/presentation/screens/create_story_screen.dart
new file mode 100644
index 0000000..b37ea78
--- /dev/null
+++ b/lib/features/social/presentation/screens/create_story_screen.dart
@@ -0,0 +1,914 @@
+import 'dart:async';
+import 'dart:typed_data';
+import 'package:cached_network_image/cached_network_image.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:go_router/go_router.dart';
+import 'package:image_picker/image_picker.dart';
+import 'package:http/http.dart' as http;
+
+import '../../../../core/theme/app_colors.dart';
+import '../../../../core/theme/app_theme.dart';
+import '../../../../core/widgets/app_snack_bar.dart';
+import '../../../pet_profile/data/models/pet.dart';
+import '../../../pet_profile/presentation/controllers/active_pet_controller.dart';
+import '../controllers/create_post_controller.dart';
+
+// ─────────────────────────────────────────────────────────────────────────────
+// CreateStoryScreen
+// ─────────────────────────────────────────────────────────────────────────────
+
+class CreateStoryScreen extends ConsumerStatefulWidget {
+ const CreateStoryScreen({super.key});
+
+ @override
+ ConsumerState createState() => _CreateStoryScreenState();
+}
+
+class _CreateStoryScreenState extends ConsumerState {
+ final _picker = ImagePicker();
+ bool _isDownloadingMock = false;
+ Uint8List? _previewBytes;
+
+ static const _mockPetImages = [
+ 'https://images.unsplash.com/photo-1543466835-00a7907e9de1?w=600&auto=format&fit=crop', // Golden Retriever
+ 'https://images.unsplash.com/photo-1514888286974-6c03e2ca1dba?w=600&auto=format&fit=crop', // Cat close-up
+ 'https://images.unsplash.com/photo-1583511655857-d19b40a7a54e?w=600&auto=format&fit=crop', // Dog with glasses
+ 'https://images.unsplash.com/photo-1573865526739-10659fec78a5?w=600&auto=format&fit=crop', // Cat sleeping
+ 'https://images.unsplash.com/photo-1537151608828-ea2b117b6281?w=600&auto=format&fit=crop', // Puppy
+ 'https://images.unsplash.com/photo-1495360010541-f48722b34f7d?w=600&auto=format&fit=crop', // Cat looking up
+ 'https://images.unsplash.com/photo-1477884213960-b13d27793fc8?w=600&auto=format&fit=crop', // Dog running
+ 'https://images.unsplash.com/photo-1533738363-b7f9aef128ce?w=600&auto=format&fit=crop', // Cat in sunglasses
+ 'https://images.unsplash.com/photo-1517849845537-4d257902454a?w=600&auto=format&fit=crop', // Pug
+ ];
+
+ @override
+ void initState() {
+ super.initState();
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ ref.read(createPostControllerProvider.notifier).setIsStory(true);
+ });
+ }
+
+ // ── Image picking ──────────────────────────────────────────────────────────
+
+ Future _loadPreviewBytes(XFile file) async {
+ final bytes = await file.readAsBytes();
+ if (mounted) setState(() => _previewBytes = bytes);
+ }
+
+ Future _pickFromCamera() async {
+ final pickedFile = await _picker.pickImage(
+ source: ImageSource.camera,
+ maxWidth: 1920,
+ imageQuality: 85,
+ );
+ if (pickedFile != null && mounted) {
+ ref.read(createPostControllerProvider.notifier).setImage(pickedFile);
+ await _loadPreviewBytes(pickedFile);
+ }
+ }
+
+ Future _pickFromGallery() async {
+ final pickedFile = await _picker.pickImage(
+ source: ImageSource.gallery,
+ maxWidth: 1920,
+ imageQuality: 85,
+ );
+ if (pickedFile != null && mounted) {
+ ref.read(createPostControllerProvider.notifier).setImage(pickedFile);
+ await _loadPreviewBytes(pickedFile);
+ }
+ }
+
+ Future _selectMockImage(String url) async {
+ setState(() => _isDownloadingMock = true);
+ try {
+ final response = await http.get(Uri.parse(url));
+ if (response.statusCode == 200) {
+ final xFile = XFile.fromData(
+ response.bodyBytes,
+ mimeType: 'image/jpeg',
+ name: 'mock_pet_${DateTime.now().millisecondsSinceEpoch}.jpg',
+ );
+ ref.read(createPostControllerProvider.notifier).setImage(xFile);
+ if (mounted) setState(() => _previewBytes = response.bodyBytes);
+ } else {
+ throw Exception('Failed to load image');
+ }
+ } catch (e) {
+ AppSnackBar.showError('Could not load mock photo: $e');
+ } finally {
+ if (mounted) {
+ setState(() => _isDownloadingMock = false);
+ }
+ }
+ }
+
+ // ── Submit ─────────────────────────────────────────────────────────────────
+
+ void _submit() async {
+ final pet = ref.read(activePetControllerProvider);
+ if (pet == null) return;
+
+ final notifier = ref.read(createPostControllerProvider.notifier);
+ notifier.setCaption(''); // Stories don't have captions
+ final success = await notifier.submit(pet.id);
+
+ if (success && mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: const Row(
+ children: [
+ Icon(Icons.check_circle_rounded, color: Colors.white, size: 18),
+ SizedBox(width: 10),
+ Text('Story shared!',
+ style: TextStyle(fontFamily: 'Inter', fontWeight: FontWeight.w600)),
+ ],
+ ),
+ backgroundColor: AppColors.success,
+ behavior: SnackBarBehavior.floating,
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
+ margin: const EdgeInsets.fromLTRB(16, 0, 16, 16),
+ duration: const Duration(seconds: 3),
+ ),
+ );
+ context.pop();
+ }
+ }
+
+ // ── Build ──────────────────────────────────────────────────────────────────
+
+ @override
+ Widget build(BuildContext context) {
+ final state = ref.watch(createPostControllerProvider);
+ final pet = ref.watch(activePetControllerProvider);
+
+ if (state.image != null) {
+ return _buildStoryPreview(state, pet);
+ } else {
+ return _buildMediaSelector();
+ }
+ }
+
+ // ── State 1: Media Selector View ───────────────────────────────────────────
+
+ Widget _buildMediaSelector() {
+ final pt = Theme.of(context).extension()!;
+ final cs = Theme.of(context).colorScheme;
+
+ return Stack(
+ children: [
+ Scaffold(
+ backgroundColor: pt.surface1,
+ appBar: AppBar(
+ backgroundColor: cs.surface,
+ elevation: 0,
+ leading: IconButton(
+ icon: Icon(Icons.close_rounded, color: cs.onSurface, size: 22),
+ onPressed: () => context.pop(),
+ tooltip: 'Close',
+ ),
+ title: const Text(
+ 'Add to Story',
+ style: TextStyle(
+ fontFamily: 'Sora',
+ fontWeight: FontWeight.w700,
+ fontSize: 16,
+ color: Colors.black,
+ ),
+ ),
+ centerTitle: true,
+ ),
+ body: CustomScrollView(
+ slivers: [
+ // Viewfinder camera button
+ SliverToBoxAdapter(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ _CameraViewfinderCard(onTap: _pickFromCamera),
+ const SizedBox(height: 8),
+ ],
+ ),
+ ),
+
+ // Recent Gallery Title
+ SliverToBoxAdapter(
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(20, 16, 20, 12),
+ child: Text(
+ 'Recent Photos',
+ style: TextStyle(
+ fontFamily: 'Sora',
+ fontWeight: FontWeight.w700,
+ fontSize: 15,
+ color: pt.ink500,
+ ),
+ ),
+ ),
+ ),
+
+ // Gallery grid list
+ SliverPadding(
+ padding: const EdgeInsets.symmetric(horizontal: 20),
+ sliver: SliverGrid(
+ gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
+ crossAxisCount: 3,
+ crossAxisSpacing: 8,
+ mainAxisSpacing: 8,
+ childAspectRatio: 1,
+ ),
+ delegate: SliverChildBuilderDelegate(
+ (context, index) {
+ if (index == 0) {
+ return _BrowseLibraryTile(onTap: _pickFromGallery);
+ }
+ final url = _mockPetImages[index - 1];
+ return _MockImageTile(
+ url: url,
+ onTap: () => _selectMockImage(url),
+ );
+ },
+ childCount: _mockPetImages.length + 1,
+ ),
+ ),
+ ),
+
+ const SliverToBoxAdapter(
+ child: SizedBox(height: 32),
+ ),
+ ],
+ ),
+ ),
+
+ // Download Loader Overlay
+ if (_isDownloadingMock)
+ Container(
+ color: Colors.black54,
+ alignment: Alignment.center,
+ child: Container(
+ padding: const EdgeInsets.all(24),
+ decoration: BoxDecoration(
+ color: cs.surface,
+ borderRadius: BorderRadius.circular(16),
+ ),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const CircularProgressIndicator(
+ color: AppColors.sunset500,
+ ),
+ const SizedBox(height: 16),
+ Text(
+ 'Loading photo…',
+ style: TextStyle(
+ fontFamily: 'Sora',
+ fontWeight: FontWeight.w600,
+ color: cs.onSurface,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ );
+ }
+
+ // ── State 2: Fullscreen 9:16 Preview View ──────────────────────────────────
+
+ Widget _buildStoryPreview(CreatePostState state, Pet? pet) {
+ return Scaffold(
+ backgroundColor: Colors.black,
+ body: Stack(
+ fit: StackFit.expand,
+ children: [
+ // 1. Fullscreen Preview Image
+ Positioned.fill(
+ child: _previewBytes != null
+ ? Image.memory(_previewBytes!, fit: BoxFit.cover)
+ : const Center(child: CircularProgressIndicator()),
+ ),
+
+ // 2. Gradients overlay for visual text readability
+ Positioned(
+ top: 0,
+ left: 0,
+ right: 0,
+ child: Container(
+ height: 160,
+ decoration: BoxDecoration(
+ gradient: LinearGradient(
+ begin: Alignment.topCenter,
+ end: Alignment.bottomCenter,
+ colors: [
+ Colors.black.withAlpha(160),
+ Colors.transparent,
+ ],
+ ),
+ ),
+ ),
+ ),
+ Positioned(
+ bottom: 0,
+ left: 0,
+ right: 0,
+ child: Container(
+ height: 200,
+ decoration: BoxDecoration(
+ gradient: LinearGradient(
+ begin: Alignment.bottomCenter,
+ end: Alignment.topCenter,
+ colors: [
+ Colors.black.withAlpha(180),
+ Colors.transparent,
+ ],
+ ),
+ ),
+ ),
+ ),
+
+ // 3. Floating Pet Badge (Top-Left)
+ if (pet != null)
+ Positioned(
+ top: 54,
+ left: 16,
+ child: Row(
+ children: [
+ CircleAvatar(
+ radius: 18,
+ backgroundColor: Colors.white.withAlpha(50),
+ backgroundImage: pet.avatarUrl != null
+ ? CachedNetworkImageProvider(pet.avatarUrl!)
+ : null,
+ child: pet.avatarUrl == null
+ ? Text(
+ pet.name.isNotEmpty ? pet.name[0].toUpperCase() : '?',
+ style: const TextStyle(
+ color: Colors.white,
+ fontWeight: FontWeight.bold,
+ fontSize: 14,
+ ),
+ )
+ : null,
+ ),
+ const SizedBox(width: 10),
+ Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ pet.name,
+ style: const TextStyle(
+ color: Colors.white,
+ fontWeight: FontWeight.bold,
+ fontSize: 14,
+ fontFamily: 'Sora',
+ shadows: [
+ Shadow(blurRadius: 3.0, color: Colors.black45, offset: Offset(1.0, 1.0)),
+ ],
+ ),
+ ),
+ const Text(
+ 'Your story preview',
+ style: TextStyle(
+ color: Colors.white70,
+ fontSize: 11,
+ fontFamily: 'Inter',
+ shadows: [
+ Shadow(blurRadius: 2.0, color: Colors.black45, offset: Offset(1.0, 1.0)),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+
+ // 4. Discard button (Top-Right)
+ Positioned(
+ top: 48,
+ right: 16,
+ child: Container(
+ decoration: BoxDecoration(
+ shape: BoxShape.circle,
+ color: Colors.black.withAlpha(100),
+ ),
+ child: IconButton(
+ icon: const Icon(Icons.close_rounded, color: Colors.white, size: 24),
+ onPressed: () {
+ ref.read(createPostControllerProvider.notifier).removeImage();
+ setState(() => _previewBytes = null);
+ },
+ ),
+ ),
+ ),
+
+ // 5. Bottom Share controller
+ Positioned(
+ bottom: 40,
+ left: 24,
+ right: 24,
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Container(
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(30),
+ gradient: const LinearGradient(
+ colors: [AppColors.sunset500, AppColors.coral500],
+ ),
+ boxShadow: [
+ BoxShadow(
+ color: AppColors.sunset500.withAlpha(100),
+ blurRadius: 16,
+ offset: const Offset(0, 6),
+ ),
+ ],
+ ),
+ child: ElevatedButton(
+ onPressed: _submit,
+ style: ElevatedButton.styleFrom(
+ backgroundColor: Colors.transparent,
+ shadowColor: Colors.transparent,
+ minimumSize: const Size(double.infinity, 54),
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(30)),
+ ),
+ child: const Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Icon(Icons.send_rounded, color: Colors.white, size: 18),
+ SizedBox(width: 8),
+ Text(
+ 'Share to Story',
+ style: TextStyle(
+ color: Colors.white,
+ fontFamily: 'Sora',
+ fontSize: 16,
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ const SizedBox(height: 12),
+ const Text(
+ 'Stories expire automatically after 24 hours',
+ style: TextStyle(
+ color: Colors.white60,
+ fontFamily: 'Inter',
+ fontSize: 11,
+ ),
+ ),
+ ],
+ ),
+ ),
+
+ // 6. Full-screen upload overlay
+ if (state.isSubmitting)
+ _UploadOverlay(step: state.step, cs: Theme.of(context).colorScheme),
+ ],
+ ),
+ );
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// _BrowseLibraryTile
+// ─────────────────────────────────────────────────────────────────────────────
+
+class _BrowseLibraryTile extends StatelessWidget {
+ const _BrowseLibraryTile({required this.onTap});
+ final VoidCallback onTap;
+
+ @override
+ Widget build(BuildContext context) {
+ final pt = Theme.of(context).extension()!;
+ final cs = Theme.of(context).colorScheme;
+
+ return InkWell(
+ onTap: onTap,
+ borderRadius: BorderRadius.circular(16),
+ child: Container(
+ decoration: BoxDecoration(
+ color: cs.surfaceContainerHighest.withAlpha(80),
+ borderRadius: BorderRadius.circular(16),
+ border: Border.all(color: pt.line200, width: 1.5),
+ ),
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Container(
+ width: 40,
+ height: 40,
+ decoration: const BoxDecoration(
+ shape: BoxShape.circle,
+ color: Colors.white,
+ boxShadow: [
+ BoxShadow(color: Colors.black12, blurRadius: 4, offset: Offset(0, 2)),
+ ],
+ ),
+ child: const Icon(
+ Icons.photo_library_rounded,
+ color: AppColors.sunset500,
+ size: 18,
+ ),
+ ),
+ const SizedBox(height: 8),
+ Text(
+ 'Browse',
+ style: TextStyle(
+ fontFamily: 'Inter',
+ fontWeight: FontWeight.w600,
+ fontSize: 12,
+ color: pt.ink500,
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// _MockImageTile
+// ─────────────────────────────────────────────────────────────────────────────
+
+class _MockImageTile extends StatelessWidget {
+ const _MockImageTile({required this.url, required this.onTap});
+ final String url;
+ final VoidCallback onTap;
+
+ @override
+ Widget build(BuildContext context) {
+ return ClipRRect(
+ borderRadius: BorderRadius.circular(16),
+ child: InkWell(
+ onTap: onTap,
+ child: CachedNetworkImage(
+ imageUrl: url,
+ fit: BoxFit.cover,
+ memCacheWidth: 200,
+ placeholder: (context, _) => Container(
+ color: Theme.of(context).colorScheme.surfaceContainerHighest,
+ ),
+ errorWidget: (context, url, error) => const Center(
+ child: Icon(Icons.error_outline_rounded, size: 16),
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// _CameraViewfinderCard
+// ─────────────────────────────────────────────────────────────────────────────
+
+class _CameraViewfinderCard extends StatefulWidget {
+ const _CameraViewfinderCard({required this.onTap});
+ final VoidCallback onTap;
+
+ @override
+ State<_CameraViewfinderCard> createState() => _CameraViewfinderCardState();
+}
+
+class _CameraViewfinderCardState extends State<_CameraViewfinderCard>
+ with SingleTickerProviderStateMixin {
+ late final AnimationController _pulseController;
+ bool _isRedDotVisible = true;
+ Timer? _redDotTimer;
+
+ @override
+ void initState() {
+ super.initState();
+ _pulseController = AnimationController(
+ vsync: this,
+ duration: const Duration(seconds: 2),
+ )..repeat(reverse: true);
+
+ _redDotTimer = Timer.periodic(const Duration(milliseconds: 700), (timer) {
+ if (mounted) {
+ setState(() => _isRedDotVisible = !_isRedDotVisible);
+ }
+ });
+ }
+
+ @override
+ void dispose() {
+ _pulseController.dispose();
+ _redDotTimer?.cancel();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final pt = Theme.of(context).extension()!;
+ final cs = Theme.of(context).colorScheme;
+
+ return GestureDetector(
+ onTap: widget.onTap,
+ child: AspectRatio(
+ aspectRatio: 4 / 3,
+ child: Container(
+ margin: const EdgeInsets.all(16),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(24),
+ gradient: const LinearGradient(
+ begin: Alignment.topLeft,
+ end: Alignment.bottomRight,
+ colors: [
+ Colors.black87,
+ Colors.black,
+ ],
+ ),
+ boxShadow: pt.shadowE2,
+ ),
+ child: ClipRRect(
+ borderRadius: BorderRadius.circular(24),
+ child: Stack(
+ fit: StackFit.expand,
+ children: [
+ // 1. Grid lines
+ CustomPaint(
+ painter: _CameraGridPainter(color: Colors.white.withAlpha(30)),
+ ),
+
+ // 2. Viewfinder corners
+ CustomPaint(
+ painter: _CameraViewfinderBracketsPainter(color: Colors.white.withAlpha(160)),
+ ),
+
+ // 3. REC blinking light
+ Positioned(
+ top: 16,
+ left: 16,
+ child: Row(
+ children: [
+ AnimatedContainer(
+ duration: const Duration(milliseconds: 100),
+ width: 8,
+ height: 8,
+ decoration: BoxDecoration(
+ shape: BoxShape.circle,
+ color: _isRedDotVisible ? Colors.red : Colors.transparent,
+ ),
+ ),
+ const SizedBox(width: 6),
+ Text(
+ 'REC',
+ style: TextStyle(
+ color: Colors.white.withAlpha(200),
+ fontFamily: 'Inter',
+ fontSize: 10,
+ fontWeight: FontWeight.w700,
+ letterSpacing: 1.0,
+ ),
+ ),
+ ],
+ ),
+ ),
+
+ // 4. Info badge
+ Positioned(
+ top: 16,
+ right: 16,
+ child: Text(
+ 'RAW · 4:3 · HDR',
+ style: TextStyle(
+ color: Colors.white.withAlpha(140),
+ fontFamily: 'Inter',
+ fontSize: 10,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ),
+
+ // 5. Central capture trigger
+ Center(
+ child: ScaleTransition(
+ scale: Tween(begin: 0.96, end: 1.04).animate(
+ CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut),
+ ),
+ child: Container(
+ width: 76,
+ height: 76,
+ decoration: BoxDecoration(
+ shape: BoxShape.circle,
+ color: Colors.white.withAlpha(30),
+ border: Border.all(color: Colors.white, width: 3),
+ ),
+ alignment: Alignment.center,
+ child: Container(
+ width: 60,
+ height: 60,
+ decoration: const BoxDecoration(
+ shape: BoxShape.circle,
+ color: Colors.white,
+ ),
+ alignment: Alignment.center,
+ child: Icon(
+ Icons.camera_alt_rounded,
+ color: cs.primary,
+ size: 28,
+ ),
+ ),
+ ),
+ ),
+ ),
+
+ // 6. Autofocus box indicator
+ Positioned(
+ bottom: 24,
+ left: 24,
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
+ decoration: BoxDecoration(
+ color: Colors.black.withAlpha(120),
+ borderRadius: BorderRadius.circular(8),
+ ),
+ child: Row(
+ children: [
+ const Icon(Icons.center_focus_weak_rounded, color: AppColors.sunset500, size: 14),
+ const SizedBox(width: 4),
+ Text(
+ 'AF-S Auto',
+ style: TextStyle(
+ color: Colors.white.withAlpha(200),
+ fontSize: 10,
+ fontFamily: 'Inter',
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Viewfinder Custom Painters
+// ─────────────────────────────────────────────────────────────────────────────
+
+class _CameraGridPainter extends CustomPainter {
+ const _CameraGridPainter({required this.color});
+ final Color color;
+
+ @override
+ void paint(Canvas canvas, Size size) {
+ final paint = Paint()
+ ..color = color
+ ..strokeWidth = 1.0
+ ..style = PaintingStyle.stroke;
+
+ // Draw horizontal lines
+ canvas.drawLine(Offset(0, size.height / 3), Offset(size.width, size.height / 3), paint);
+ canvas.drawLine(Offset(0, 2 * size.height / 3), Offset(size.width, 2 * size.height / 3), paint);
+
+ // Draw vertical lines
+ canvas.drawLine(Offset(size.width / 3, 0), Offset(size.width / 3, size.height), paint);
+ canvas.drawLine(Offset(2 * size.width / 3, 0), Offset(2 * size.width / 3, size.height), paint);
+ }
+
+ @override
+ bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
+}
+
+class _CameraViewfinderBracketsPainter extends CustomPainter {
+ const _CameraViewfinderBracketsPainter({required this.color});
+ final Color color;
+
+ @override
+ void paint(Canvas canvas, Size size) {
+ final paint = Paint()
+ ..color = color
+ ..strokeWidth = 2.5
+ ..style = PaintingStyle.stroke;
+
+ const length = 20.0;
+ const padding = 16.0;
+
+ // Top-Left Corner
+ canvas.drawPath(
+ Path()
+ ..moveTo(padding, padding + length)
+ ..lineTo(padding, padding)
+ ..lineTo(padding + length, padding),
+ paint,
+ );
+
+ // Top-Right Corner
+ canvas.drawPath(
+ Path()
+ ..moveTo(size.width - padding - length, padding)
+ ..lineTo(size.width - padding, padding)
+ ..lineTo(size.width - padding, padding + length),
+ paint,
+ );
+
+ // Bottom-Left Corner
+ canvas.drawPath(
+ Path()
+ ..moveTo(padding, size.height - padding - length)
+ ..lineTo(padding, size.height - padding)
+ ..lineTo(padding + length, size.height - padding),
+ paint,
+ );
+
+ // Bottom-Right Corner
+ canvas.drawPath(
+ Path()
+ ..moveTo(size.width - padding - length, size.height - padding)
+ ..lineTo(size.width - padding, size.height - padding)
+ ..lineTo(size.width - padding, size.height - padding - length),
+ paint,
+ );
+ }
+
+ @override
+ bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// _UploadOverlay
+// ─────────────────────────────────────────────────────────────────────────────
+
+class _UploadOverlay extends StatelessWidget {
+ const _UploadOverlay({required this.step, required this.cs});
+ final PostStep step;
+ final ColorScheme cs;
+
+ @override
+ Widget build(BuildContext context) {
+ final label = step == PostStep.uploading ? 'Uploading photo…' : 'Sharing story…';
+ final sub = step == PostStep.uploading
+ ? 'Please wait while we upload your image'
+ : 'Almost there, hang tight!';
+
+ return Container(
+ color: Colors.black54,
+ alignment: Alignment.center,
+ child: Container(
+ margin: const EdgeInsets.symmetric(horizontal: 40),
+ padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 28),
+ decoration: BoxDecoration(
+ color: cs.surface,
+ borderRadius: BorderRadius.circular(20),
+ boxShadow: const [
+ BoxShadow(
+ color: Colors.black26,
+ blurRadius: 24,
+ offset: Offset(0, 8),
+ ),
+ ],
+ ),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const SizedBox(
+ width: 48,
+ height: 48,
+ child: CircularProgressIndicator(
+ strokeWidth: 3,
+ color: AppColors.sunset500,
+ ),
+ ),
+ const SizedBox(height: 20),
+ Text(
+ label,
+ textAlign: TextAlign.center,
+ style: TextStyle(
+ fontFamily: 'Sora',
+ fontWeight: FontWeight.w700,
+ fontSize: 15,
+ color: cs.onSurface,
+ ),
+ ),
+ const SizedBox(height: 6),
+ Text(
+ sub,
+ textAlign: TextAlign.center,
+ style: TextStyle(
+ fontFamily: 'Inter',
+ fontSize: 13,
+ color: cs.onSurface.withAlpha(140),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+
diff --git a/lib/features/social/presentation/screens/post_detail_screen.dart b/lib/features/social/presentation/screens/post_detail_screen.dart
index bbb9300..0666bd3 100644
--- a/lib/features/social/presentation/screens/post_detail_screen.dart
+++ b/lib/features/social/presentation/screens/post_detail_screen.dart
@@ -43,12 +43,15 @@ class PostDetailScreen extends ConsumerStatefulWidget {
class _PostDetailScreenState extends ConsumerState {
final _commentController = TextEditingController();
final _scrollController = ScrollController();
+ final _commentFocusNode = FocusNode();
+ Comment? _replyingToComment;
bool _isSending = false;
@override
void dispose() {
_commentController.dispose();
_scrollController.dispose();
+ _commentFocusNode.dispose();
super.dispose();
}
@@ -64,11 +67,17 @@ class _PostDetailScreenState extends ConsumerState {
setState(() => _isSending = true);
try {
+ final parentId = _replyingToComment?.parentId ?? _replyingToComment?.id;
await ref
.read(commentListProvider(widget.postId).notifier)
- .add(petId: activePet.id, content: text);
+ .add(
+ petId: activePet.id,
+ content: text,
+ parentId: parentId,
+ );
_commentController.clear();
+ setState(() => _replyingToComment = null);
// Scroll to the bottom after posting.
await Future.delayed(const Duration(milliseconds: 100));
@@ -162,32 +171,35 @@ class _PostDetailScreenState extends ConsumerState {
color: Theme.of(context).colorScheme.onSurface),
onPressed: () => context.pop(),
),
- title: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- CircleAvatar(
- radius: 14,
- backgroundColor: post.accentColor,
- backgroundImage: post.petAvatarUrl != null
- ? CachedNetworkImageProvider(post.petAvatarUrl!)
- : null,
- child: post.petAvatarUrl == null
- ? Text(
- post.petName.isNotEmpty ? post.petName[0].toUpperCase() : '?',
- style: tt.headlineSmall?.copyWith(
- fontSize: 10,
- fontWeight: FontWeight.w700,
- color: Colors.white,
- ),
- )
- : null,
- ),
- const SizedBox(width: 8),
- Text(
- post.petName,
- style: tt.headlineSmall?.copyWith(fontWeight: FontWeight.w700),
- ),
- ],
+ title: GestureDetector(
+ onTap: () => context.push('/social/profile/${post.petId}'),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ CircleAvatar(
+ radius: 14,
+ backgroundColor: post.accentColor,
+ backgroundImage: post.petAvatarUrl != null
+ ? CachedNetworkImageProvider(post.petAvatarUrl!)
+ : null,
+ child: post.petAvatarUrl == null
+ ? Text(
+ post.petName.isNotEmpty ? post.petName[0].toUpperCase() : '?',
+ style: tt.headlineSmall?.copyWith(
+ fontSize: 10,
+ fontWeight: FontWeight.w700,
+ color: Colors.white,
+ ),
+ )
+ : null,
+ ),
+ const SizedBox(width: 8),
+ Text(
+ post.petName,
+ style: tt.headlineSmall?.copyWith(fontWeight: FontWeight.w700),
+ ),
+ ],
+ ),
),
centerTitle: true,
actions: [
@@ -250,25 +262,56 @@ class _PostDetailScreenState extends ConsumerState {
),
),
),
- data: (list) => list.isEmpty
- ? SliverToBoxAdapter(
- child: Padding(
- padding: const EdgeInsets.symmetric(
- vertical: 32, horizontal: 16),
- child: Text(
- 'No comments yet. Be the first!',
- textAlign: TextAlign.center,
- style: TextStyle(color: pt.ink500, fontSize: 14),
- ),
- ),
- )
- : SliverList.builder(
- itemCount: list.length,
- itemBuilder: (ctx, i) => _CommentTile(
- comment: list[i],
- postId: widget.postId,
+ data: (list) {
+ if (list.isEmpty) {
+ return SliverToBoxAdapter(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(
+ vertical: 32, horizontal: 16),
+ child: Text(
+ 'No comments yet. Be the first!',
+ textAlign: TextAlign.center,
+ style: TextStyle(color: pt.ink500, fontSize: 14),
),
),
+ );
+ }
+
+ // Pre-group replies by parentId for O(n) thread building
+ final repliesByParentId = >{};
+ for (final c in list) {
+ if (c.parentId != null) {
+ repliesByParentId.putIfAbsent(c.parentId!, () => []).add(c);
+ }
+ }
+ final rootComments = list.where((c) => c.parentId == null).toList();
+ final displayList = <_CommentDisplayItem>[];
+ for (final root in rootComments) {
+ displayList.add(_CommentDisplayItem(comment: root, isReply: false));
+ for (final reply in repliesByParentId[root.id] ?? const []) {
+ displayList.add(_CommentDisplayItem(comment: reply, isReply: true));
+ }
+ }
+
+ return SliverList.builder(
+ itemCount: displayList.length,
+ itemBuilder: (ctx, i) {
+ final item = displayList[i];
+ return _CommentTile(
+ key: ValueKey(item.comment.id),
+ comment: item.comment,
+ postId: widget.postId,
+ isReply: item.isReply,
+ onReplyTap: (c) {
+ setState(() {
+ _replyingToComment = c;
+ });
+ _commentFocusNode.requestFocus();
+ },
+ );
+ },
+ );
+ },
),
const SliverToBoxAdapter(child: SizedBox(height: 16)),
@@ -276,13 +319,45 @@ class _PostDetailScreenState extends ConsumerState {
),
),
- // ── Fixed comment input bar ─────────────────────────────────────
- _CommentInputBar(
- controller: _commentController,
- isSending: _isSending,
- onSend: _sendComment,
- pt: pt,
- autofocus: widget.autofocusComment,
+ // ── Fixed comment input bar with Replying Banner ──────────────────────────
+ Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ if (_replyingToComment != null)
+ Container(
+ decoration: BoxDecoration(
+ color: Theme.of(context).colorScheme.surface,
+ border: Border(top: BorderSide(color: pt.line200)),
+ ),
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+ child: Row(
+ children: [
+ Expanded(
+ child: Text(
+ 'Replying to ${_replyingToComment!.handle}',
+ style: tt.bodySmall?.copyWith(
+ color: pt.ink500,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ),
+ GestureDetector(
+ onTap: () => setState(() => _replyingToComment = null),
+ child: Icon(Icons.close_rounded, size: 16, color: pt.ink300),
+ ),
+ ],
+ ),
+ ),
+ _CommentInputBar(
+ controller: _commentController,
+ focusNode: _commentFocusNode,
+ isSending: _isSending,
+ onSend: _sendComment,
+ pt: pt,
+ autofocus: widget.autofocusComment,
+ replyingToHandle: _replyingToComment?.handle,
+ ),
+ ],
),
],
),
@@ -323,7 +398,7 @@ class _PostImagesState extends State<_PostImages> {
if (post.imageUrls.isEmpty) {
// Fallback: gradient blob
return AspectRatio(
- aspectRatio: 1,
+ aspectRatio: 4 / 5,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
@@ -339,7 +414,7 @@ class _PostImagesState extends State<_PostImages> {
return Stack(
children: [
AspectRatio(
- aspectRatio: 1,
+ aspectRatio: 4 / 5,
child: PageView.builder(
itemCount: post.imageUrls.length,
onPageChanged: (i) => setState(() => _currentPage = i),
@@ -430,7 +505,7 @@ class _StatsBar extends ConsumerWidget {
// Like button
IconButton(
icon: Icon(
- post.isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
+ post.isLiked ? Icons.pets_rounded : Icons.pets_outlined,
color: post.isLiked ? AppColors.coral500 : pt.ink500,
),
onPressed: () {
@@ -464,94 +539,366 @@ class _StatsBar extends ConsumerWidget {
}
}
+class _CommentDisplayItem {
+ const _CommentDisplayItem({required this.comment, required this.isReply});
+ final Comment comment;
+ final bool isReply;
+}
+
// ─────────────────────────────────────────────────────────────────────────────
// Comment tile
// ─────────────────────────────────────────────────────────────────────────────
class _CommentTile extends ConsumerWidget {
- const _CommentTile({required this.comment, required this.postId});
+ const _CommentTile({
+ super.key,
+ required this.comment,
+ required this.postId,
+ this.isReply = false,
+ this.onReplyTap,
+ });
+
final Comment comment;
final String postId;
+ final bool isReply;
+ final ValueChanged? onReplyTap;
+
+ // ── Context menu ───────────────────────────────────────────────────────────
+
+ /// Shows the owner context menu on long-press.
+ /// Silently returns if the comment belongs to another pet.
+ void _showContextMenu(BuildContext context, WidgetRef ref) {
+ if (!comment.isOwnComment) return;
- @override
- Widget build(BuildContext context, WidgetRef ref) {
- final pt = Theme.of(context).extension()!;
final tt = Theme.of(context).textTheme;
+ final cs = Theme.of(context).colorScheme;
- return Padding(
- padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
- child: Row(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- // Avatar
- CircleAvatar(
- radius: 16,
- backgroundColor: AppColors.coral500.withAlpha(200),
- backgroundImage: comment.avatarUrl != null
- ? CachedNetworkImageProvider(comment.avatarUrl!)
- : null,
- child: comment.avatarUrl == null
- ? Text(
- comment.petName.isNotEmpty
- ? comment.petName[0].toUpperCase()
- : '?',
- style: tt.titleSmall?.copyWith(
- fontSize: 12,
- fontWeight: FontWeight.w700,
- color: Colors.white,
- ),
- )
- : null,
- ),
- const SizedBox(width: 10),
- // Content
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Row(
- children: [
- Text(
- comment.petName,
- style: tt.labelMedium?.copyWith(
- fontWeight: FontWeight.w700,
- color: Theme.of(context).colorScheme.onSurface,
- ),
- ),
- const SizedBox(width: 6),
- Text(
- comment.timeAgo,
- style: tt.labelSmall?.copyWith(color: pt.ink500),
- ),
- ],
+ showModalBottomSheet(
+ context: context,
+ useRootNavigator: true,
+ backgroundColor: cs.surface,
+ shape: const RoundedRectangleBorder(
+ borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
+ ),
+ builder: (sheetCtx) {
+ return SafeArea(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ // Drag handle
+ Padding(
+ padding: const EdgeInsets.only(top: 12, bottom: 8),
+ child: Container(
+ width: 36,
+ height: 4,
+ decoration: BoxDecoration(
+ color: cs.onSurfaceVariant.withAlpha(60),
+ borderRadius: BorderRadius.circular(2),
+ ),
),
- const SizedBox(height: 2),
- Text(
+ ),
+ // Preview of the comment text
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
+ child: Text(
comment.content,
- style: tt.bodySmall?.copyWith(height: 1.4),
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ style: tt.bodySmall?.copyWith(
+ color: cs.onSurfaceVariant,
+ height: 1.4,
+ ),
),
- ],
- ),
+ ),
+ const Divider(height: 1),
+ // Edit action
+ ListTile(
+ leading: Icon(Icons.edit_rounded, color: cs.onSurface),
+ title: Text(
+ 'Edit Comment',
+ style: tt.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
+ ),
+ onTap: () {
+ Navigator.of(sheetCtx).pop();
+ // Small delay so the first sheet is fully dismissed first.
+ Future.delayed(const Duration(milliseconds: 120), () {
+ if (context.mounted) _showEditSheet(context, ref);
+ });
+ },
+ ),
+ // Delete action
+ ListTile(
+ leading: const Icon(Icons.delete_outline_rounded,
+ color: AppColors.coral500),
+ title: Text(
+ 'Delete Comment',
+ style: tt.bodyMedium?.copyWith(
+ color: AppColors.coral500,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ onTap: () {
+ Navigator.of(sheetCtx).pop();
+ ref
+ .read(commentListProvider(postId).notifier)
+ .delete(comment.id);
+ },
+ ),
+ const SizedBox(height: 8),
+ ],
),
- // Delete button (only for own comments)
- if (comment.isOwnComment)
+ );
+ },
+ );
+ }
+
+ /// Opens an edit bottom sheet pre-filled with the current comment text.
+ void _showEditSheet(BuildContext context, WidgetRef ref) {
+ final tt = Theme.of(context).textTheme;
+ final cs = Theme.of(context).colorScheme;
+ final editController = TextEditingController(text: comment.content);
+
+ showModalBottomSheet(
+ context: context,
+ useRootNavigator: true,
+ isScrollControlled: true, // allows sheet to grow with keyboard
+ backgroundColor: cs.surface,
+ shape: const RoundedRectangleBorder(
+ borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
+ ),
+ builder: (sheetCtx) {
+ return StatefulBuilder(
+ builder: (ctx, setSheetState) {
+ return Padding(
+ padding: EdgeInsets.only(
+ left: 20,
+ right: 20,
+ top: 20,
+ bottom: MediaQuery.of(ctx).viewInsets.bottom + 24,
+ ),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ // Drag handle
+ Center(
+ child: Container(
+ width: 36,
+ height: 4,
+ decoration: BoxDecoration(
+ color: cs.onSurfaceVariant.withAlpha(60),
+ borderRadius: BorderRadius.circular(2),
+ ),
+ ),
+ ),
+ const SizedBox(height: 16),
+ Text(
+ 'Edit Comment',
+ style: tt.titleMedium?.copyWith(fontWeight: FontWeight.w700),
+ ),
+ const SizedBox(height: 12),
+ TextField(
+ controller: editController,
+ autofocus: true,
+ maxLines: 5,
+ minLines: 1,
+ style: tt.bodyMedium,
+ decoration: InputDecoration(
+ hintText: 'Update your comment…',
+ hintStyle: tt.bodyMedium?.copyWith(
+ color: cs.onSurfaceVariant,
+ ),
+ filled: true,
+ fillColor: cs.surfaceContainerHighest,
+ border: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(12),
+ borderSide: BorderSide.none,
+ ),
+ contentPadding: const EdgeInsets.symmetric(
+ horizontal: 14,
+ vertical: 12,
+ ),
+ ),
+ ),
+ const SizedBox(height: 16),
+ FilledButton(
+ style: FilledButton.styleFrom(
+ backgroundColor: AppColors.coral500,
+ foregroundColor: Colors.white,
+ minimumSize: const Size.fromHeight(48),
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(12),
+ ),
+ ),
+ onPressed: () {
+ final newText = editController.text.trim();
+ if (newText.isEmpty) return;
+ Navigator.of(sheetCtx).pop();
+ ref
+ .read(commentListProvider(postId).notifier)
+ .edit(comment.id, newText);
+ },
+ child: Text(
+ 'Save',
+ style: tt.labelLarge?.copyWith(
+ fontWeight: FontWeight.w700,
+ color: Colors.white,
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ },
+ );
+ },
+ ).whenComplete(() => editController.dispose());
+ }
+
+ // ── Build ──────────────────────────────────────────────────────────────────
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final pt = Theme.of(context).extension()!;
+ final tt = Theme.of(context).textTheme;
+
+ return GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onLongPress: () => _showContextMenu(context, ref),
+ child: Padding(
+ padding: EdgeInsets.only(
+ left: isReply ? 52.0 : 16.0,
+ right: 8.0,
+ top: 8.0,
+ bottom: 8.0,
+ ),
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ // Tappable avatar → pet social profile
GestureDetector(
- onTap: () => ref
- .read(commentListProvider(postId).notifier)
- .delete(comment.id),
- child: Padding(
- padding: const EdgeInsets.only(left: 8, top: 2),
- child: Icon(Icons.close_rounded, size: 16, color: pt.ink300),
+ onTap: () => context.push('/social/profile/${comment.petId}'),
+ child: CircleAvatar(
+ radius: isReply ? 12 : 16,
+ backgroundColor: AppColors.coral500.withAlpha(200),
+ backgroundImage: comment.avatarUrl != null
+ ? CachedNetworkImageProvider(comment.avatarUrl!)
+ : null,
+ child: comment.avatarUrl == null
+ ? Text(
+ comment.petName.isNotEmpty
+ ? comment.petName[0].toUpperCase()
+ : '?',
+ style: tt.titleSmall?.copyWith(
+ fontSize: isReply ? 9 : 12,
+ fontWeight: FontWeight.w700,
+ color: Colors.white,
+ ),
+ )
+ : null,
),
),
- ],
+ const SizedBox(width: 10),
+ // Content
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ // Tappable pet name → pet social profile
+ GestureDetector(
+ onTap: () =>
+ context.push('/social/profile/${comment.petId}'),
+ child: Text(
+ comment.petName,
+ style: tt.labelMedium?.copyWith(
+ fontWeight: FontWeight.w700,
+ color: Theme.of(context).colorScheme.onSurface,
+ ),
+ ),
+ ),
+ const SizedBox(width: 6),
+ Text(
+ comment.timeAgo,
+ style: tt.labelSmall?.copyWith(color: pt.ink500),
+ ),
+ // "• edited" hint for visual feedback after an edit
+ if (comment.isOwnComment) ...[
+ const SizedBox(width: 4),
+ Text(
+ '· hold to edit',
+ style: tt.labelSmall?.copyWith(
+ color: pt.ink500.withAlpha(120),
+ fontSize: 10,
+ ),
+ ),
+ ],
+ ],
+ ),
+ const SizedBox(height: 2),
+ Text(
+ comment.content,
+ style: tt.bodySmall?.copyWith(height: 1.4),
+ ),
+ const SizedBox(height: 4),
+ // Actions row — like count + Reply link only
+ Row(
+ children: [
+ if (comment.likeCount > 0) ...[
+ Text(
+ '${comment.likeCount} ${comment.likeCount == 1 ? 'like' : 'likes'}',
+ style: tt.labelSmall?.copyWith(
+ color: pt.ink500,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ const SizedBox(width: 12),
+ ],
+ GestureDetector(
+ onTap: () => onReplyTap?.call(comment),
+ child: Text(
+ 'Reply',
+ style: tt.labelSmall?.copyWith(
+ color: pt.ink500,
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ // Like button (paw icon) on the right
+ IconButton(
+ icon: Icon(
+ comment.isLiked ? Icons.pets_rounded : Icons.pets_outlined,
+ size: 16,
+ color: comment.isLiked ? AppColors.coral500 : pt.ink300,
+ ),
+ padding: EdgeInsets.zero,
+ constraints: const BoxConstraints(
+ minWidth: 32,
+ minHeight: 32,
+ ),
+ onPressed: () {
+ ref
+ .read(commentListProvider(postId).notifier)
+ .toggleLike(comment.id);
+ },
+ ),
+ ],
+ ),
),
);
}
}
+
// ─────────────────────────────────────────────────────────────────────────────
// Comment input bar (fixed at bottom)
+
// ─────────────────────────────────────────────────────────────────────────────
class _CommentInputBar extends StatelessWidget {
@@ -561,6 +908,8 @@ class _CommentInputBar extends StatelessWidget {
required this.onSend,
required this.pt,
this.autofocus = false,
+ this.focusNode,
+ this.replyingToHandle,
});
final TextEditingController controller;
@@ -568,6 +917,8 @@ class _CommentInputBar extends StatelessWidget {
final VoidCallback onSend;
final PetfolioThemeExtension pt;
final bool autofocus;
+ final FocusNode? focusNode;
+ final String? replyingToHandle;
@override
Widget build(BuildContext context) {
@@ -594,6 +945,7 @@ class _CommentInputBar extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: TextField(
controller: controller,
+ focusNode: focusNode,
autofocus: autofocus,
minLines: 1,
maxLines: 4,
@@ -604,7 +956,9 @@ class _CommentInputBar extends StatelessWidget {
decoration: InputDecoration(
border: InputBorder.none,
isDense: true,
- hintText: 'Add a comment...',
+ hintText: replyingToHandle != null
+ ? 'Reply to $replyingToHandle...'
+ : 'Add a comment...',
hintStyle: TextStyle(color: pt.ink300, fontSize: 14),
contentPadding: EdgeInsets.zero,
),
diff --git a/lib/features/social/presentation/screens/social_screen.dart b/lib/features/social/presentation/screens/social_screen.dart
index 6147b81..1998305 100644
--- a/lib/features/social/presentation/screens/social_screen.dart
+++ b/lib/features/social/presentation/screens/social_screen.dart
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:share_plus/share_plus.dart';
+import 'package:supabase_flutter/supabase_flutter.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/theme/app_theme.dart';
@@ -12,7 +13,11 @@ import '../../../pet_profile/presentation/controllers/active_pet_controller.dart
import '../../../pet_profile/presentation/controllers/pet_list_controller.dart';
import '../../../pet_profile/presentation/widgets/pet_switcher_sheet.dart';
import '../../data/models/feed_post.dart';
+import '../../data/models/story.dart';
import '../controllers/social_controller.dart';
+import '../controllers/create_post_controller.dart';
+import '../controllers/story_controller.dart';
+import 'story_viewer_screen.dart';
// ─────────────────────────────────────────────────────────────────────────────
// Entry point
@@ -148,8 +153,7 @@ class _SocialViewState extends ConsumerState<_SocialView> {
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
SliverToBoxAdapter(
- child: _StoriesRow(
- posts: feedState.posts, pet: widget.pet),
+ child: _StoriesRow(pet: widget.pet),
),
if (feedState.posts.isEmpty)
SliverFillRemaining(
@@ -208,7 +212,10 @@ class _SocialViewState extends ConsumerState<_SocialView> {
),
),
floatingActionButton: FloatingActionButton.extended(
- onPressed: () => context.push('/social/create'),
+ onPressed: () {
+ ref.read(createPostControllerProvider.notifier).setIsStory(false);
+ context.push('/social/create-post');
+ },
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Colors.white,
elevation: 4,
@@ -226,27 +233,179 @@ class _SocialViewState extends ConsumerState<_SocialView> {
// Stories row
// ─────────────────────────────────────────────────────────────────────────────
-class _StoriesRow extends StatelessWidget {
- const _StoriesRow({required this.posts, required this.pet});
- final List posts;
+class _StoriesRow extends ConsumerWidget {
+ const _StoriesRow({required this.pet});
final Pet pet;
@override
- Widget build(BuildContext context) {
- return SizedBox(
- height: 96,
- child: ListView(
- scrollDirection: Axis.horizontal,
- padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
- children: [
- _StoryItem(
- initial: pet.name.isNotEmpty ? pet.name[0].toUpperCase() : '?',
- label: 'Your story',
- ringColors: const [AppColors.sunset500, AppColors.coral500],
- isAdd: true,
- onTap: () => context.push('/social/create'),
+ Widget build(BuildContext context, WidgetRef ref) {
+ final storiesAsync = ref.watch(storiesProvider);
+ final pt = Theme.of(context).extension()!;
+ final userId = Supabase.instance.client.auth.currentUser?.id;
+
+ return storiesAsync.when(
+ loading: () => const SizedBox(
+ height: 96,
+ child: Center(child: CircularProgressIndicator.adaptive()),
+ ),
+ error: (err, stack) => const SizedBox.shrink(),
+ data: (stories) {
+ // Group stories by petId
+ final grouped = >{};
+ for (final story in stories) {
+ grouped.putIfAbsent(story.petId, () => []).add(story);
+ }
+
+ final stacks = grouped.entries.map((e) {
+ final first = e.value.first;
+ final sortedStories = List.from(e.value)
+ ..sort((a, b) => a.createdAt.compareTo(b.createdAt));
+ return PetStoryStack(
+ petId: e.key,
+ petName: first.petName,
+ petAvatarUrl: first.petAvatarUrl,
+ petSpecies: first.petSpecies,
+ stories: sortedStories,
+ );
+ }).toList();
+
+ // Check if active pet has stories
+ final activePetStackIdx = stacks.indexWhere((s) => s.petId == pet.id);
+ final activePetStack = activePetStackIdx != -1 ? stacks[activePetStackIdx] : null;
+
+ // Other pets' stacks, sorted with unviewed ones first
+ final otherStacks = stacks.where((s) => s.petId != pet.id).toList()
+ ..sort((a, b) {
+ final aUnviewed = a.hasUnviewed(userId);
+ final bUnviewed = b.hasUnviewed(userId);
+ if (aUnviewed && !bUnviewed) return -1;
+ if (!aUnviewed && bUnviewed) return 1;
+ final aNewest = a.stories.map((s) => s.createdAt).reduce((v, e) => v.isAfter(e) ? v : e);
+ final bNewest = b.stories.map((s) => s.createdAt).reduce((v, e) => v.isAfter(e) ? v : e);
+ return bNewest.compareTo(aNewest);
+ });
+
+ return SizedBox(
+ height: 96,
+ child: ListView(
+ scrollDirection: Axis.horizontal,
+ padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
+ children: [
+ // Own Pet Story Item
+ if (activePetStack != null)
+ _StoryItem(
+ initial: pet.name.isNotEmpty ? pet.name[0].toUpperCase() : '?',
+ label: 'Your story',
+ avatarUrl: pet.avatarUrl,
+ ringColors: activePetStack.hasUnviewed(userId)
+ ? const [AppColors.sunset500, AppColors.coral500]
+ : [pt.ink300, pt.ink300],
+ onTap: () => context.push('/social/stories?petId=${pet.id}'),
+ onLongPress: () => _showOwnStoryOptions(context, ref, pet),
+ )
+ else
+ _StoryItem(
+ initial: pet.name.isNotEmpty ? pet.name[0].toUpperCase() : '?',
+ label: 'Your story',
+ avatarUrl: pet.avatarUrl,
+ ringColors: const [AppColors.sunset500, AppColors.coral500],
+ isAdd: true,
+ onTap: () {
+ ref.read(createPostControllerProvider.notifier).setIsStory(true);
+ context.push('/social/create-story');
+ },
+ ),
+
+ // Other Pet Story Items
+ ...otherStacks.map((stack) {
+ final initial = stack.petName.isNotEmpty ? stack.petName[0].toUpperCase() : '?';
+ return Padding(
+ padding: const EdgeInsets.only(left: 12),
+ child: _StoryItem(
+ initial: initial,
+ label: stack.petName,
+ avatarUrl: stack.petAvatarUrl,
+ ringColors: stack.hasUnviewed(userId)
+ ? const [AppColors.sunset500, AppColors.coral500]
+ : [pt.ink300, pt.ink300],
+ onTap: () => context.push('/social/stories?petId=${stack.petId}'),
+ ),
+ );
+ }),
+ ],
),
- ],
+ );
+ },
+ );
+ }
+
+ void _showOwnStoryOptions(BuildContext context, WidgetRef ref, Pet pet) {
+ showModalBottomSheet(
+ context: context,
+ shape: const RoundedRectangleBorder(
+ borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
+ ),
+ builder: (_) => _OwnStoryOptionsSheet(pet: pet),
+ );
+ }
+}
+
+class _OwnStoryOptionsSheet extends ConsumerWidget {
+ const _OwnStoryOptionsSheet({required this.pet});
+ final Pet pet;
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final cs = Theme.of(context).colorScheme;
+
+ return SafeArea(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(vertical: 8),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Padding(
+ padding: const EdgeInsets.symmetric(vertical: 12),
+ child: Text(
+ '${pet.name}\'s Story',
+ style: Theme.of(context).textTheme.titleMedium?.copyWith(
+ fontFamily: 'Sora',
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ ),
+ const Divider(),
+ ListTile(
+ leading: Icon(Icons.play_circle_outline_rounded, color: cs.primary),
+ title: const Text(
+ 'View story',
+ style: TextStyle(
+ fontFamily: 'Inter',
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ onTap: () {
+ Navigator.pop(context);
+ context.push('/social/stories?petId=${pet.id}');
+ },
+ ),
+ ListTile(
+ leading: Icon(Icons.add_photo_alternate_outlined, color: cs.primary),
+ title: const Text(
+ 'Add to story',
+ style: TextStyle(
+ fontFamily: 'Inter',
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ onTap: () {
+ Navigator.pop(context);
+ ref.read(createPostControllerProvider.notifier).setIsStory(true);
+ context.push('/social/create-story');
+ },
+ ),
+ ],
+ ),
),
);
}
@@ -257,61 +416,92 @@ class _StoryItem extends StatelessWidget {
required this.initial,
required this.label,
required this.ringColors,
+ this.avatarUrl,
this.isAdd = false,
this.onTap,
+ this.onLongPress,
});
+
final String initial;
final String label;
final List ringColors;
+ final String? avatarUrl;
final bool isAdd;
final VoidCallback? onTap;
+ final VoidCallback? onLongPress;
@override
Widget build(BuildContext context) {
final tt = Theme.of(context).textTheme;
final surface = Theme.of(context).colorScheme.surface;
- final avatarBg = ringColors.isNotEmpty ? ringColors[0].withAlpha(180) : AppColors.blue500;
return GestureDetector(
onTap: onTap,
+ onLongPress: onLongPress,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Ring + avatar
- Container(
- width: 58,
- height: 58,
- decoration: BoxDecoration(
- shape: BoxShape.circle,
- gradient: LinearGradient(
- begin: Alignment.topLeft,
- end: Alignment.bottomRight,
- colors: ringColors.length >= 2
- ? ringColors
- : [ringColors.first, ringColors.first],
- ),
- ),
- padding: const EdgeInsets.all(2.5),
- child: Container(
- decoration: BoxDecoration(
- shape: BoxShape.circle,
- color: surface,
- ),
- padding: const EdgeInsets.all(2),
- child: CircleAvatar(
- backgroundColor: avatarBg,
- child: isAdd
- ? const Icon(Icons.add, color: Colors.white, size: 20)
- : Text(
- initial,
- style: tt.titleSmall?.copyWith(
- fontSize: 17,
- fontWeight: FontWeight.w700,
- color: Colors.white,
- ),
- ),
+ Stack(
+ children: [
+ Container(
+ width: 58,
+ height: 58,
+ decoration: BoxDecoration(
+ shape: BoxShape.circle,
+ gradient: LinearGradient(
+ begin: Alignment.topLeft,
+ end: Alignment.bottomRight,
+ colors: ringColors.length >= 2
+ ? ringColors
+ : [ringColors.first, ringColors.first],
+ ),
+ ),
+ padding: const EdgeInsets.all(2.5),
+ child: Container(
+ decoration: BoxDecoration(
+ shape: BoxShape.circle,
+ color: surface,
+ ),
+ padding: const EdgeInsets.all(2),
+ child: CircleAvatar(
+ backgroundColor: ringColors.first.withAlpha(180),
+ backgroundImage: avatarUrl != null
+ ? CachedNetworkImageProvider(avatarUrl!)
+ : null,
+ child: avatarUrl == null
+ ? Text(
+ initial,
+ style: tt.titleSmall?.copyWith(
+ fontSize: 17,
+ fontWeight: FontWeight.w700,
+ color: Colors.white,
+ ),
+ )
+ : null,
+ ),
+ ),
),
- ),
+ if (isAdd)
+ Positioned(
+ right: 0,
+ bottom: 0,
+ child: Container(
+ width: 20,
+ height: 20,
+ decoration: BoxDecoration(
+ color: AppColors.sunset500,
+ shape: BoxShape.circle,
+ border: Border.all(color: surface, width: 2),
+ ),
+ child: const Icon(
+ Icons.add,
+ color: Colors.white,
+ size: 14,
+ ),
+ ),
+ ),
+ ],
),
const SizedBox(height: 4),
SizedBox(
@@ -529,7 +719,7 @@ class _PostPhotoState extends State<_PostPhoto>
onTap: widget.onTap,
onDoubleTap: _handleDoubleTap,
child: AspectRatio(
- aspectRatio: 4 / 3,
+ aspectRatio: 4 / 5,
child: Container(
decoration: BoxDecoration(
gradient: post.imageUrls.isNotEmpty
@@ -637,7 +827,7 @@ class _PostPhotoState extends State<_PostPhoto>
reverseCurve: Curves.easeIn,
),
child: Icon(
- Icons.favorite_rounded,
+ Icons.pets_rounded,
size: 100,
color: Colors.white.withAlpha(220),
),
@@ -704,8 +894,8 @@ class _RegularFooter extends StatelessWidget {
),
child: Icon(
post.isLiked
- ? Icons.favorite_rounded
- : Icons.favorite_border_rounded,
+ ? Icons.pets_rounded
+ : Icons.pets_outlined,
key: ValueKey(post.isLiked),
color: post.isLiked ? AppColors.coral500 : pt.ink500,
size: 24,
diff --git a/lib/features/social/presentation/screens/story_viewer_screen.dart b/lib/features/social/presentation/screens/story_viewer_screen.dart
new file mode 100644
index 0000000..2ea46a1
--- /dev/null
+++ b/lib/features/social/presentation/screens/story_viewer_screen.dart
@@ -0,0 +1,483 @@
+import 'dart:async';
+import 'package:cached_network_image/cached_network_image.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:go_router/go_router.dart';
+
+import '../../data/models/story.dart';
+import '../controllers/story_controller.dart';
+
+class StoryViewerScreen extends ConsumerStatefulWidget {
+ const StoryViewerScreen({
+ super.key,
+ required this.initialPetId,
+ });
+
+ final String initialPetId;
+
+ @override
+ ConsumerState createState() => _StoryViewerScreenState();
+}
+
+class _StoryViewerScreenState extends ConsumerState {
+ late final PageController _pageController;
+ int _currentPetIndex = 0;
+ int _currentStoryIndex = 0;
+
+ // Stacks of stories grouped by pet
+ List _petStacks = [];
+ bool _initialized = false;
+
+ // Timer & progress management
+ Timer? _timer;
+ double _progress = 0.0;
+ static const int _storyDurationMs = 5000; // 5 seconds per story
+ static const int _tickMs = 50; // Update progress every 50ms
+ bool _isPaused = false;
+
+ @override
+ void initState() {
+ super.initState();
+ _pageController = PageController();
+ }
+
+ @override
+ void dispose() {
+ _timer?.cancel();
+ _pageController.dispose();
+ super.dispose();
+ }
+
+ void _setupStacks(List allStories) {
+ if (_initialized) return;
+
+ final grouped = >{};
+ for (final story in allStories) {
+ grouped.putIfAbsent(story.petId, () => []).add(story);
+ }
+
+ final stacks = grouped.entries.map((e) {
+ final first = e.value.first;
+ final sortedStories = List.from(e.value)
+ ..sort((a, b) => a.createdAt.compareTo(b.createdAt));
+ return PetStoryStack(
+ petId: e.key,
+ petName: first.petName,
+ petAvatarUrl: first.petAvatarUrl,
+ petSpecies: first.petSpecies,
+ stories: sortedStories,
+ );
+ }).toList();
+
+ int initialIndex = stacks.indexWhere((s) => s.petId == widget.initialPetId);
+ if (initialIndex == -1) initialIndex = 0;
+
+ // Defer setState to avoid calling it during build
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (!mounted) return;
+ setState(() {
+ _petStacks = stacks;
+ _currentPetIndex = initialIndex;
+ _initialized = true;
+ });
+ if (_pageController.hasClients) {
+ _pageController.jumpToPage(initialIndex);
+ }
+ _startStory();
+ });
+ }
+
+ void _startStory({double fromProgress = 0.0}) {
+ _timer?.cancel();
+ if (_petStacks.isEmpty) return;
+
+ setState(() {
+ _progress = fromProgress;
+ _isPaused = false;
+ });
+
+ if (fromProgress == 0.0) {
+ final currentStory = _petStacks[_currentPetIndex].stories[_currentStoryIndex];
+ ref.read(storiesProvider.notifier).markStoryViewed(currentStory.id);
+ }
+
+ final totalTicks = _storyDurationMs / _tickMs;
+ final increment = 1.0 / totalTicks;
+
+ _timer = Timer.periodic(const Duration(milliseconds: _tickMs), (timer) {
+ if (_isPaused) return;
+
+ setState(() {
+ _progress += increment;
+ });
+
+ if (_progress >= 1.0) {
+ _timer?.cancel();
+ _nextStory();
+ }
+ });
+ }
+
+ void _nextStory() {
+ final currentStack = _petStacks[_currentPetIndex];
+ if (_currentStoryIndex < currentStack.stories.length - 1) {
+ // Next story in the same pet's stack
+ setState(() {
+ _currentStoryIndex++;
+ });
+ _startStory();
+ } else {
+ // Last story in current stack -> go to next pet stack
+ _nextPet();
+ }
+ }
+
+ void _previousStory() {
+ if (_currentStoryIndex > 0) {
+ setState(() {
+ _currentStoryIndex--;
+ });
+ _startStory();
+ } else {
+ // First story in current stack -> go to previous pet stack
+ _previousPet();
+ }
+ }
+
+ void _nextPet() {
+ if (_currentPetIndex < _petStacks.length - 1) {
+ setState(() {
+ _currentPetIndex++;
+ _currentStoryIndex = 0;
+ });
+ _pageController.animateToPage(
+ _currentPetIndex,
+ duration: const Duration(milliseconds: 300),
+ curve: Curves.easeInOut,
+ );
+ _startStory();
+ } else {
+ // All stories completed, close viewer
+ context.pop();
+ }
+ }
+
+ void _previousPet() {
+ if (_currentPetIndex > 0) {
+ setState(() {
+ _currentPetIndex--;
+ // When going back to previous pet, start at their last story slide
+ _currentStoryIndex = _petStacks[_currentPetIndex].stories.length - 1;
+ });
+ _pageController.animateToPage(
+ _currentPetIndex,
+ duration: const Duration(milliseconds: 300),
+ curve: Curves.easeInOut,
+ );
+ _startStory();
+ } else {
+ // At the very first story slide, restart it
+ _startStory();
+ }
+ }
+
+ void _pause() {
+ setState(() {
+ _isPaused = true;
+ });
+ }
+
+ void _resume() {
+ setState(() {
+ _isPaused = false;
+ });
+ }
+
+ String _timeAgo(DateTime time) {
+ final difference = DateTime.now().difference(time);
+ if (difference.inMinutes < 1) return 'now';
+ if (difference.inMinutes < 60) return '${difference.inMinutes}m';
+ if (difference.inHours < 24) return '${difference.inHours}h';
+ return '${difference.inDays}d';
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final storiesAsync = ref.watch(storiesProvider);
+
+ return Scaffold(
+ backgroundColor: Colors.black,
+ body: storiesAsync.when(
+ loading: () => const Center(
+ child: CircularProgressIndicator.adaptive(
+ valueColor: AlwaysStoppedAnimation(Colors.white),
+ ),
+ ),
+ error: (err, _) => Center(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const Icon(Icons.error_outline_rounded, color: Colors.white, size: 48),
+ const SizedBox(height: 12),
+ Text(
+ 'Failed to load stories: $err',
+ style: const TextStyle(color: Colors.white),
+ ),
+ const SizedBox(height: 16),
+ ElevatedButton(
+ onPressed: () => ref.invalidate(storiesProvider),
+ child: const Text('Retry'),
+ ),
+ ],
+ ),
+ ),
+ data: (stories) {
+ if (stories.isEmpty) {
+ WidgetsBinding.instance.addPostFrameCallback((_) => context.pop());
+ return const SizedBox.shrink();
+ }
+
+ _setupStacks(stories);
+
+ if (_petStacks.isEmpty) {
+ return const SizedBox.shrink();
+ }
+
+ final activeStack = _petStacks[_currentPetIndex];
+ final activeStory = activeStack.stories[_currentStoryIndex];
+
+ return SafeArea(
+ child: Stack(
+ fit: StackFit.expand,
+ children: [
+ // Horizontal PageView for swiping between pets
+ PageView.builder(
+ controller: _pageController,
+ physics: const NeverScrollableScrollPhysics(), // Let custom tap/slide handle page changes
+ itemCount: _petStacks.length,
+ itemBuilder: (context, petIdx) {
+ final stack = _petStacks[petIdx];
+ // Display the current slide only for the active page
+ final story = (petIdx == _currentPetIndex)
+ ? stack.stories[_currentStoryIndex]
+ : stack.stories.first;
+
+ return Center(
+ child: AspectRatio(
+ aspectRatio: 9 / 16,
+ child: Container(
+ decoration: BoxDecoration(
+ color: Colors.black,
+ borderRadius: BorderRadius.circular(12),
+ ),
+ clipBehavior: Clip.antiAlias,
+ child: CachedNetworkImage(
+ imageUrl: story.imageUrl,
+ fit: BoxFit.cover,
+ memCacheWidth: 1080,
+ placeholder: (context, url) => const Center(
+ child: CircularProgressIndicator.adaptive(
+ valueColor: AlwaysStoppedAnimation(Colors.white),
+ ),
+ ),
+ errorWidget: (context, url, error) => const Center(
+ child: Icon(Icons.error_outline_rounded, color: Colors.white, size: 40),
+ ),
+ ),
+ ),
+ ),
+ );
+ },
+ ),
+
+ // Touch controller overlay
+ Positioned.fill(
+ child: Row(
+ children: [
+ // Left tap area -> Previous
+ Expanded(
+ child: GestureDetector(
+ behavior: HitTestBehavior.translucent,
+ onTap: _previousStory,
+ onLongPress: _pause,
+ onLongPressUp: _resume,
+ ),
+ ),
+ // Middle long-press area & right tap area -> Next
+ Expanded(
+ child: GestureDetector(
+ behavior: HitTestBehavior.translucent,
+ onTap: _nextStory,
+ onLongPress: _pause,
+ onLongPressUp: _resume,
+ ),
+ ),
+ ],
+ ),
+ ),
+
+ // Story Header & Segmented Progress Bars
+ Positioned(
+ top: 10,
+ left: 10,
+ right: 10,
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ // 1. Progress bars
+ Row(
+ children: List.generate(
+ activeStack.stories.length,
+ (idx) {
+ double widthFactor = 0.0;
+ if (idx < _currentStoryIndex) {
+ widthFactor = 1.0;
+ } else if (idx == _currentStoryIndex) {
+ widthFactor = _progress;
+ }
+
+ return Expanded(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 2.0),
+ child: Container(
+ height: 3,
+ decoration: BoxDecoration(
+ color: Colors.white.withAlpha(70),
+ borderRadius: BorderRadius.circular(1.5),
+ ),
+ alignment: Alignment.centerLeft,
+ child: FractionallySizedBox(
+ widthFactor: widthFactor.clamp(0.0, 1.0),
+ child: Container(
+ decoration: BoxDecoration(
+ color: Colors.white,
+ borderRadius: BorderRadius.circular(1.5),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ },
+ ),
+ ),
+ const SizedBox(height: 12),
+
+ // 2. Pet Info
+ Row(
+ children: [
+ Expanded(
+ child: GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onTap: () {
+ _timer?.cancel();
+ final savedProgress = _progress;
+ context.push('/social/profile/${activeStack.petId}').then((_) {
+ if (mounted) {
+ _startStory(fromProgress: savedProgress);
+ }
+ });
+ },
+ child: Row(
+ children: [
+ CircleAvatar(
+ radius: 18,
+ backgroundColor: Colors.white.withAlpha(50),
+ backgroundImage: activeStack.petAvatarUrl != null
+ ? CachedNetworkImageProvider(activeStack.petAvatarUrl!)
+ : null,
+ child: activeStack.petAvatarUrl == null
+ ? Text(
+ activeStack.petName.isNotEmpty
+ ? activeStack.petName[0].toUpperCase()
+ : '?',
+ style: const TextStyle(
+ color: Colors.white,
+ fontWeight: FontWeight.bold,
+ fontSize: 14,
+ ),
+ )
+ : null,
+ ),
+ const SizedBox(width: 10),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ activeStack.petName,
+ style: const TextStyle(
+ color: Colors.white,
+ fontWeight: FontWeight.bold,
+ fontSize: 14,
+ fontFamily: 'Sora',
+ shadows: [
+ Shadow(
+ blurRadius: 3.0,
+ color: Colors.black45,
+ offset: Offset(1.0, 1.0),
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(height: 1),
+ Text(
+ _timeAgo(activeStory.createdAt),
+ style: TextStyle(
+ color: Colors.white.withAlpha(180),
+ fontSize: 11,
+ fontFamily: 'Inter',
+ shadows: const [
+ Shadow(
+ blurRadius: 2.0,
+ color: Colors.black45,
+ offset: Offset(1.0, 1.0),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ IconButton(
+ icon: const Icon(Icons.close_rounded, color: Colors.white, size: 24),
+ onPressed: () => context.pop(),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ },
+ ),
+ );
+ }
+}
+
+class PetStoryStack {
+ final String petId;
+ final String petName;
+ final String? petAvatarUrl;
+ final String petSpecies;
+ final List stories;
+
+ const PetStoryStack({
+ required this.petId,
+ required this.petName,
+ this.petAvatarUrl,
+ required this.petSpecies,
+ required this.stories,
+ });
+
+ bool hasUnviewed(String? userId) {
+ if (userId == null) return false;
+ return stories.any((s) => !s.viewedByUsers.contains(userId));
+ }
+}
diff --git a/lib/main.dart b/lib/main.dart
index fba0eb7..52bfd8f 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -6,7 +6,8 @@ import 'package:marionette_flutter/marionette_flutter.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'marionette_debug_gate_stub.dart'
- if (dart.library.io) 'marionette_debug_gate_io.dart' as marionette_gate;
+ if (dart.library.io) 'marionette_debug_gate_io.dart'
+ as marionette_gate;
import 'core/router.dart';
import 'core/services/notification_service.dart';
import 'core/theme/theme.dart';
@@ -51,10 +52,7 @@ Future main() async {
Stripe.merchantIdentifier = 'merchant.com.petfolio';
await Stripe.instance.applySettings();
- await Supabase.initialize(
- url: _supabaseUrl,
- anonKey: _supabaseAnonKey,
- );
+ await Supabase.initialize(url: _supabaseUrl, anonKey: _supabaseAnonKey);
await NotificationService.instance.initialize();
@@ -67,16 +65,17 @@ class PetfolioApp extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final router = ref.watch(routerProvider);
+ final themeMode = ref.watch(themeProvider);
return MaterialApp.router(
- title: 'Petfolio',
+ title: 'PetFolio',
debugShowCheckedModeBanner: false,
scaffoldMessengerKey: appSnackBarMessengerKey,
// ── Design system themes ─────────────────────────────────────────────
theme: AppTheme.light(),
darkTheme: AppTheme.dark(),
- themeMode: ThemeMode.system,
+ themeMode: themeMode,
routerConfig: router,
);
diff --git a/progress.md b/progress.md
index c087c62..af77a93 100644
--- a/progress.md
+++ b/progress.md
@@ -1,5 +1,133 @@
# Petfolio — Progress Log
+
+## AI AGENT HANDOVER & ARCHITECTURE GUIDE
+
+> [!IMPORTANT]
+> Incoming developer AI agents must read and adhere to this section. It outlines the codebase design patterns, state management practices, routing constraints, and current feature layouts to prevent regression or architectural drift.
+
+### 1. Technology Stack & Directory Structure
+- **Architecture**: Strict **Feature-First Architecture** inside [lib/features/](file:///home/kratzer/workspace/petfolio/lib/features/). Cleanly split each feature into `presentation` (screens, controllers/notifiers), `domain` (business logic, pure models), and `data` (repositories, data sources, API models) layers.
+- **State Management**: Standardized entirely on **Riverpod** (`flutter_riverpod`, `riverpod_annotation`, with code-generated notifiers).
+ - *Rule*: Do NOT import or introduce the legacy `provider` package.
+ - *Rule*: Riverpod 3 generated notifiers omit type parameters (`extends _$NotifierName`). Use `AsyncValue.value` instead of `.valueOrNull`.
+- **Database (Supabase)**:
+ - *RLS Optimization*: All Row-Level Security policies in Supabase must wrap authentication checks in a subselect: e.g., `(select auth.uid())`. This forces Postgres to cache the query plan and prevents heavy database jank.
+ - *Performance*: Avoid N+1 database queries on client-side joins. Push complex relational operations and aggregations to **Postgres Views** or **RPCs**. Never create queries that result in full table scans; use indexes.
+ - *Applying Schema*: Prefer using Supabase MCP migration commands if available. When using the Supabase CLI, always prefix with `npx` (e.g., `npx supabase db push`).
+
+### 2. Core Feature Areas & Routes
+
+#### A. Social Feed & Stories
+- **Create Post Screen** ([create_post_screen.dart](file:///home/kratzer/workspace/petfolio/lib/features/social/presentation/screens/create_post_screen.dart)):
+ - Route: `/social/create-post`
+ - Aspect Ratio: `4/5` vertical portrait standard.
+- **Create Story Screen** ([create_story_screen.dart](file:///home/kratzer/workspace/petfolio/lib/features/social/presentation/screens/create_story_screen.dart)):
+ - Route: `/social/create-story`
+ - UI Design: Media-first. Top section displays a DSLR-style custom painter camera viewfinder (`_CameraViewfinderCard`, `4/3` ratio) with mock indicators (RAW, HDR, record dot timer, pulsing shutter). Below sits a 3-column scrollable mock photo selector grid loaded with Unsplash pet assets plus a library browse button.
+ - Fullscreen 9:16 story preview overlay on selection with floating pet details and a sunset-gradient submit button.
+- **Story Viewer Screen** ([story_viewer_screen.dart](file:///home/kratzer/workspace/petfolio/lib/features/social/presentation/screens/story_viewer_screen.dart)):
+ - Route: `/social/stories?petId=:petId`
+ - Playback: Features segmented indicators mapping to the story list. Long-pressing the viewport pauses the story progression; releasing resumes it.
+ - Pet Identity Navigation: The top header avatar and pet info are wrapped in an opaque `GestureDetector` that pauses the active timer, pushes the profile route (`/social/profile/:petId`), and resumes the story play timer cleanly upon back navigation.
+- **Aspect Ratio Alignment**:
+ - The feed card ([social_screen.dart](file:///home/kratzer/workspace/petfolio/lib/features/social/presentation/screens/social_screen.dart#L724)), post detail ([post_detail_screen.dart](file:///home/kratzer/workspace/petfolio/lib/features/social/presentation/screens/post_detail_screen.dart#L329)), and creation preview screens are standardized to a vertical `4/5` portrait ratio (`aspectRatio: 4 / 5`) to match Instagram's standard and minimize vertical photo cropping.
+
+#### B. Care & Health
+- **Dashboard**: Accessed at `/care`. Successful onboarding redirects to `/care?onboardingComplete=1` which displays a one-shot success snackbar and normalizes the URL.
+- **Pet Context Dependency**: `careDashboardProvider` and `healthVaultControllerProvider` are non-family providers that watch `activePetIdProvider`. If `activePetId` is null, remote loads are skipped and lists are returned empty.
+- **Completion Streaks & Badges**:
+ - Daily streak completion is driven by the `check_daily_completion` RPC (`target_pet_id`, optional `completion_date` matching `care_logs.logged_date`) checking against `care_tasks` and `care_logs`.
+ - Streak records live in `care_streaks` and badges in `pet_badges`.
+ - The dashboard listens to Supabase realtime on `care_streaks` via `careStreakRealtimeProvider` for instant UI updates.
+
+#### C. Multi-Vendor Marketplace
+- **Discover Shops**: Navigate via `shopListProvider` to `/shop/:id`.
+- **Seller Setup & Onboarding**: Path `/seller/setup` triggers the `stripe-onboard-vendor` Edge Function utilizing `functions.invoke` (not `.rpc()`) with body `{'shopId'}` to return the Stripe Connect setup link.
+- **Checkout Flow**: Handled via cart `itemsByShop` and per-shop `startCheckoutForShop` workflows.
+
+### 3. Critical UI & Navigation Constraints
+- **Circular Imports Avoidance**: Do NOT import [router.dart](file:///home/kratzer/workspace/petfolio/lib/core/router.dart) from screens that the router imports. Instead, navigate using literal path strings or query parameter deep links.
+- **Error UI Handling**: For optimistic UI actions (like toggling care tasks or seller onboarding), show errors using `AppSnackBar.showError` (via `appSnackBarMessengerKey` on `MaterialApp.router`). Do not put long-lived state providers in `AsyncValue.error` states for transient/action-level failures.
+- **Web Safe Target**: Marionette execution runs exclusively in debug builds via conditional compiler imports (`marionette_debug_gate_stub.dart` vs `_io.dart`) to keep `main.dart` from importing `dart:io` on web targets.
+
+## 2026-05-25 — Comment Likes and Threaded Replies
+
+- **Database migration applied** (`jqyjvhwlcqcsuwcqgcwf`): added `parent_id` (foreign key to comments table for threading) and `like_count` to comments table; created `comment_likes` table with triggers to keep counts updated; added RLS security policy wrapping `(select auth.uid())` for plan caching performance.
+- **Comment Likes (Paw Icon & Optimistic UI)**: Added a small paw icon button (`Icons.pets_rounded` / `Icons.pets_outlined`) on each comment tile to toggle liking instantly; handles database failures optimistically by reverting local state and invoking `AppSnackBar.showError(e)`.
+- **Comment Replies (Threaded UI)**: Implemented comments threading in `PostDetailScreen`. Root comments and replies are flattened with replies indented by 52 pixels and styled with smaller avatars (`radius: 12` vs `16`) to build a clear visual hierarchy.
+- **Context-Aware Reply States**: Click "Reply" to activate the replying banner, focus the comment input bar using `FocusNode`, set a custom dynamic placeholder (`Reply to @handle...`), and automatically reset when sent or canceled.
+- `flutter analyze` — **No issues found.**
+
+---
+
+## 2026-05-24 — Paw Icon Likes Alignment
+
+- **Replaced Love with Paw Icons**: Changed post likes from heart icons (`Icons.favorite_rounded`/`Icons.favorite_border_rounded`) to paw icons (`Icons.pets_rounded`/`Icons.pets_outlined`) in both the feed [social_screen.dart](file:///home/kratzer/workspace/petfolio/lib/features/social/presentation/screens/social_screen.dart) and post details [post_detail_screen.dart](file:///home/kratzer/workspace/petfolio/lib/features/social/presentation/screens/post_detail_screen.dart).
+- **Interactive Double-Tap Overlay**: Updated the double-tap gesture like overlay animation on feed posts to render a pulsing big paw icon (`Icons.pets_rounded`) instead of a heart.
+- **Static Analysis**: Verified clean build via `flutter analyze` with **no issues found**.
+
+---
+
+## 2026-05-24 — Persistent Dark Mode Support
+
+- **Theme Mode Notifier**: Created a dynamic generated Riverpod notifier `ThemeNotifier` (generating `themeProvider`) in [theme_notifier.dart](file:///home/kratzer/workspace/petfolio/lib/core/theme/theme_notifier.dart) to replace the old static theme mode provider.
+- **Preferences Persistence**: Automatically saves and loads the user's selected `ThemeMode` from `SharedPreferences` to ensure settings persist across application restarts.
+- **Header Switch Action**: Replaced the placeholder outdoor header icon on [pet_profile_screen.dart](file:///home/kratzer/workspace/petfolio/lib/features/pet_profile/presentation/screens/pet_profile_screen.dart) with a responsive theme toggle action button in the active pet header.
+- **Static Analysis**: Verified clean build via `flutter analyze` with **no issues found**.
+
+---
+
+## 2026-05-24 — Story Viewer Profile Navigation
+
+- **Pet Profile Navigation from Story Viewer**: Wrapped the pet avatar and name/info column in `story_viewer_screen.dart` with a `GestureDetector` that pauses the active story timer and pushes the pet's profile route (`/social/profile/:petId`). The story timer automatically resumes when returning back to the story viewer.
+- **Static Analysis**: Verified clean build via `flutter analyze` with **no issues found**.
+
+---
+
+## 2026-05-24 — Instagram Post Card Aspect Ratio Alignment
+
+- **Feed Image Ratio updated**: Changed the post card photo aspect ratio in `social_screen.dart` from landscape `4/3` to `4/5` (Instagram portrait ratio) to prevent vertical cropping of pet photos.
+- **Creator Screen Preview updated**: Standardized the image preview on `create_post_screen.dart` to `4/5` to maintain UI consistency during creation.
+- **Detail Screen updated**: Updated `post_detail_screen.dart` to `4/5` aspect ratio to keep rendering consistent across the entire post lifecycle.
+- **Static Analysis**: Verified clean build via `flutter analyze` with **no issues found**.
+
+---
+
+## 2026-05-25 — Premium CreateStoryScreen Redesign
+
+- **Media-First UX Split**: Completely redesigned `CreateStoryScreen` to feature a beautiful camera/selector layout instead of form inputs.
+- **Custom Viewfinder Card**: Added a DSLR-style viewfinder card (`_CameraViewfinderCard`) with corner paint brackets, alignment grids, active REC dot timer, and shutter button linking directly to the system camera.
+- **Interactive Gallery Grid**: Added a 3-column media list. Includes a "Browse" library tile to pick custom photos and a grid of high-quality mock pet photo tiles that download and set immediately.
+- **Fullscreen 9:16 Preview**: Tapping a photo transitions into a fullscreen 9:16 layout featuring the pet's avatar badge, transparent gradient protection layers, and a glowing sunset-gradient "Share to Story" button.
+- `flutter analyze` — **No issues found.**
+
+---
+
+## 2026-05-25 — Dedicated Create Post vs Create Story Pages
+
+- **Split Pages**: Separated the single togglable creation screen into two distinct screens:
+ - `CreatePostScreen` (handles feed posts only, no toggle, requires a caption or photo, title "New Post").
+ - `CreateStoryScreen` (handles stories only, no caption or visibility inputs, requires a photo, title "New Story").
+- **Clean Routes**: Registered separate routes in `router.dart`:
+ - `/social/create-post` -> `CreatePostScreen`.
+ - `/social/create-story` -> `CreateStoryScreen`.
+- **Navigation Update**: Updated the FloatActionButton, story item `+` actions, and long-press bottom sheet actions in `social_screen.dart` to navigate directly to their respective dedicated pages.
+- `flutter analyze` — **No issues found.**
+
+---
+
+## 2026-05-25 — Story Long-Press Popup Menu Options
+
+- **Tab and Hold on Story Avatar**: Added long-press gesture support to the user's own pet story item (`_StoryItem` in `social_screen.dart`).
+- **`_OwnStoryOptionsSheet` Bottom Sheet**: Created a bottom sheet popup that prompts when long-pressing "Your story" with options:
+ - **View story**: Navigates to the active story viewer page (`/social/stories`).
+ - **Add to story**: Configures the post creation controller state to "Story" mode, then pushes the `/social/create` route.
+- **Improved Create Post/Story Entry State**:
+ - Tapping the `+` button on your story avatar or selecting "Add to story" in the long press popup menu automatically forces the selection on `CreatePostScreen` to default to the "Story" tab.
+ - Tapping the bottom navigation FloatActionButton to write a post automatically defaults the selection to the "Feed Post" tab.
+- `flutter analyze` — **No issues found.**
+
---
## 2026-05-25 — AI Routine v2: Full Pet Context + Weekly/Monthly Support
diff --git a/supabase/migrations/20260525210000_add_pet_stories.sql b/supabase/migrations/20260525210000_add_pet_stories.sql
new file mode 100644
index 0000000..aa6bd5c
--- /dev/null
+++ b/supabase/migrations/20260525210000_add_pet_stories.sql
@@ -0,0 +1,132 @@
+-- ================================================================
+-- 20260525210000_add_pet_stories.sql
+--
+-- Implements the stories table, RLS policies, RPC for tracking views,
+-- and automated cleanup for expired stories.
+-- ================================================================
+
+-- ── 1. Create stories table ──────────────────────────────────────
+CREATE TABLE IF NOT EXISTS public.stories (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ pet_id UUID NOT NULL REFERENCES public.pets(id) ON DELETE CASCADE,
+ image_url TEXT NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ viewed_by_users UUID[] NOT NULL DEFAULT '{}'::UUID[]
+);
+
+COMMENT ON TABLE public.stories IS 'Instagram-like active stories for pets (24-hour lifetime).';
+
+-- ── 2. Create indexes ────────────────────────────────────────────
+CREATE INDEX IF NOT EXISTS idx_stories_pet_id ON public.stories(pet_id);
+CREATE INDEX IF NOT EXISTS idx_stories_created_at ON public.stories(created_at);
+
+-- ── 3. Enable RLS ────────────────────────────────────────────────
+ALTER TABLE public.stories ENABLE ROW LEVEL SECURITY;
+
+-- ── 4. RLS Policies ──────────────────────────────────────────────
+
+-- SELECT policy: Authenticated users can view active stories (<= 24 hours old)
+-- from public pets OR pets they follow (via public.pet_follows).
+CREATE POLICY "stories_select_policy"
+ ON public.stories FOR SELECT TO authenticated
+ USING (
+ created_at >= now() - interval '24 hours'
+ AND (
+ EXISTS (
+ SELECT 1 FROM public.pets p
+ WHERE p.id = stories.pet_id
+ AND (p.is_public = true OR p.owner_id = (SELECT auth.uid()))
+ )
+ OR EXISTS (
+ SELECT 1 FROM public.pet_follows pf
+ WHERE pf.following_pet_id = stories.pet_id
+ AND pf.follower_pet_id IN (
+ SELECT p.id FROM public.pets p
+ WHERE p.owner_id = (SELECT auth.uid())
+ )
+ )
+ )
+ );
+
+-- INSERT policy: Authenticated users can insert stories for pets they own.
+CREATE POLICY "stories_insert_policy"
+ ON public.stories FOR INSERT TO authenticated
+ WITH CHECK (
+ EXISTS (
+ SELECT 1 FROM public.pets p
+ WHERE p.id = pet_id
+ AND p.owner_id = (SELECT auth.uid())
+ )
+ );
+
+-- DELETE policy: Authenticated users can delete stories for pets they own.
+CREATE POLICY "stories_delete_policy"
+ ON public.stories FOR DELETE TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM public.pets p
+ WHERE p.id = pet_id
+ AND p.owner_id = (SELECT auth.uid())
+ )
+ );
+
+-- ── 5. RPC to mark story as viewed ──────────────────────────────
+-- SECURITY DEFINER allows updating the viewed_by_users array
+-- securely without giving users direct UPDATE grants on the table.
+CREATE OR REPLACE FUNCTION public.mark_story_viewed(p_story_id UUID)
+RETURNS void
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+BEGIN
+ -- Verify the caller has select access (view permission) to this story
+ IF EXISTS (
+ SELECT 1 FROM public.stories s
+ WHERE s.id = p_story_id
+ AND s.created_at >= now() - interval '24 hours'
+ AND (
+ EXISTS (
+ SELECT 1 FROM public.pets p
+ WHERE p.id = s.pet_id
+ AND (p.is_public = true OR p.owner_id = (SELECT auth.uid()))
+ )
+ OR EXISTS (
+ SELECT 1 FROM public.pet_follows pf
+ WHERE pf.following_pet_id = s.pet_id
+ AND pf.follower_pet_id IN (
+ SELECT p.id FROM public.pets p
+ WHERE p.owner_id = (SELECT auth.uid())
+ )
+ )
+ )
+ ) THEN
+ UPDATE public.stories
+ SET viewed_by_users = array_append(viewed_by_users, (SELECT auth.uid()))
+ WHERE id = p_story_id
+ AND NOT ((SELECT auth.uid()) = ANY(viewed_by_users));
+ END IF;
+END;
+$$;
+
+REVOKE ALL ON FUNCTION public.mark_story_viewed(UUID) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.mark_story_viewed(UUID) TO authenticated;
+
+-- ── 6. Cleanup expired stories ───────────────────────────────────
+CREATE OR REPLACE FUNCTION public.cleanup_expired_stories()
+RETURNS void
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+BEGIN
+ DELETE FROM public.stories
+ WHERE created_at < now() - interval '24 hours';
+END;
+$$;
+
+-- To schedule with pg_cron (if available, run in dashboard or uncomment):
+-- SELECT cron.schedule('cleanup-expired-stories', '0 * * * *', 'SELECT public.cleanup_expired_stories()');
+
+-- ── 7. Grants ────────────────────────────────────────────────────
+GRANT SELECT, INSERT, DELETE ON public.stories TO authenticated;
diff --git a/supabase/migrations/20260525220000_comment_likes_replies.sql b/supabase/migrations/20260525220000_comment_likes_replies.sql
new file mode 100644
index 0000000..61fc6a4
--- /dev/null
+++ b/supabase/migrations/20260525220000_comment_likes_replies.sql
@@ -0,0 +1,64 @@
+-- Migration: comment_likes_and_replies
+-- Adds replies (parent_id) and likes to the comments table.
+
+-- 1. Alter comments table to support parent_id and like_count
+ALTER TABLE public.comments
+ ADD COLUMN IF NOT EXISTS parent_id UUID REFERENCES public.comments(id) ON DELETE CASCADE,
+ ADD COLUMN IF NOT EXISTS like_count INT NOT NULL DEFAULT 0 CHECK (like_count >= 0);
+
+-- Index for fast nested comment retrieval
+CREATE INDEX IF NOT EXISTS comments_parent_id_idx ON public.comments(parent_id);
+
+-- 2. Create comment_likes table
+CREATE TABLE IF NOT EXISTS public.comment_likes (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ comment_id UUID NOT NULL,
+ pet_id UUID NOT NULL,
+ user_id UUID NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+
+ CONSTRAINT comment_likes_comment_id_fkey FOREIGN KEY (comment_id) REFERENCES public.comments(id) ON DELETE CASCADE,
+ CONSTRAINT comment_likes_pet_id_fkey FOREIGN KEY (pet_id) REFERENCES public.pets(id) ON DELETE CASCADE,
+ CONSTRAINT comment_likes_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE,
+ CONSTRAINT comment_likes_comment_id_pet_id_key UNIQUE(comment_id, pet_id)
+);
+
+-- Row-Level Security
+ALTER TABLE public.comment_likes ENABLE ROW LEVEL SECURITY;
+
+CREATE POLICY "comment_likes: anyone can read"
+ ON public.comment_likes FOR SELECT
+ USING (true);
+
+-- Always wrap auth.uid check in a subselect for performance
+CREATE POLICY "comment_likes: insert own"
+ ON public.comment_likes FOR INSERT
+ WITH CHECK ((SELECT auth.uid()) = user_id);
+
+CREATE POLICY "comment_likes: delete own"
+ ON public.comment_likes FOR DELETE
+ USING ((SELECT auth.uid()) = user_id);
+
+-- 3. Trigger to keep like_count in sync on public.comments
+CREATE OR REPLACE FUNCTION public.handle_comment_like_sync()
+RETURNS TRIGGER AS $$
+BEGIN
+ IF (TG_OP = 'INSERT') THEN
+ UPDATE public.comments
+ SET like_count = like_count + 1
+ WHERE id = NEW.comment_id;
+ ELSIF (TG_OP = 'DELETE') THEN
+ UPDATE public.comments
+ SET like_count = GREATEST(0, like_count - 1)
+ WHERE id = OLD.comment_id;
+ END IF;
+ RETURN NULL;
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER;
+
+CREATE TRIGGER on_comment_like_change
+ AFTER INSERT OR DELETE ON public.comment_likes
+ FOR EACH ROW EXECUTE FUNCTION public.handle_comment_like_sync();
+
+-- Revoke execution permissions from anon/authenticated on trigger functions
+REVOKE EXECUTE ON FUNCTION public.handle_comment_like_sync() FROM anon, authenticated;