diff --git a/app/components/landing/CliCommand.tsx b/app/components/landing/CliCommand.tsx new file mode 100644 index 0000000..f3414a1 --- /dev/null +++ b/app/components/landing/CliCommand.tsx @@ -0,0 +1,103 @@ +'use client'; + +import * as React from 'react'; +import { CopyButton } from '@/components/animate-ui/components/buttons/copy'; +import { cn } from '@/lib/utils'; +import { motion, AnimatePresence } from 'motion/react'; + +interface CliCommandProps { + className?: string; +} + +export function CliCommand({ className }: CliCommandProps) { + const [step, setStep] = React.useState<1 | 2>(1); + const timerRef = React.useRef(null); + + const commandText = step === 1 ? 'npm i create-flutterinit' : 'npx create-flutterinit'; + + const resetToStepOne = React.useCallback(() => { + setStep(1); + }, []); + + const startResetTimer = React.useCallback(() => { + if (timerRef.current) { + clearTimeout(timerRef.current); + } + timerRef.current = setTimeout(resetToStepOne, 8000); // Reset to step 1 after 8 seconds + }, [resetToStepOne]); + + React.useEffect(() => { + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + } + }; + }, []); + + const handleCopiedChange = React.useCallback((copied: boolean) => { + if (copied) { + if (step === 1) { + setStep(2); + } + startResetTimer(); + } + }, [step, startResetTimer]); + + return ( +
+ {/* Terminal Icon */} + + + + + + {/* CLI Tag */} + + CLI + + + {/* Command Text with Animation */} +
+ + + {commandText} + + +
+ + {/* Separator */} +
+ + {/* Copy Button */} + +
+ ); +} + + diff --git a/app/components/landing/GitHubStars.tsx b/app/components/landing/GitHubStars.tsx index 990f315..b078df1 100644 --- a/app/components/landing/GitHubStars.tsx +++ b/app/components/landing/GitHubStars.tsx @@ -21,9 +21,50 @@ async function getStars() { } } -export async function GitHubStars() { +export async function GitHubStars({ variant = 'default' }: { variant?: 'default' | 'sm' }) { const stars = await getStars(); + if (variant === 'sm') { + return ( + +
+ + + GitHub + +
+ +
+ +
+ + + {stars.toLocaleString()} + +
+ + {/* Subtle glow effect on hover */} +
+ + {/* Animated background shine */} +
+ + ); + } + return ( +
+
+
+
+
+
+
+ ); + } + return (
diff --git a/app/components/landing/HeroSection.tsx b/app/components/landing/HeroSection.tsx index 68ff1a7..83cddb9 100644 --- a/app/components/landing/HeroSection.tsx +++ b/app/components/landing/HeroSection.tsx @@ -2,44 +2,63 @@ import { Button } from '@/components/ui/button'; import { ArrowRight01Icon } from '@hugeicons/core-free-icons'; import { HugeiconsIcon } from '@hugeicons/react'; import Link from 'next/link'; -import { Suspense } from 'react'; -import { GitHubStars, GitHubStarsSkeleton } from './GitHubStars'; +import { CliCommand } from './CliCommand'; import { MobileNodePattern } from './MobileNodePattern'; import { NodePattern } from './NodePattern'; +import { cn } from '@/lib/utils'; +import { KineticText } from '@/components/ui/kinetic-text'; +import { HexagonBackground } from '@/components/animate-ui/components/backgrounds/hexagon'; +import { BorderBeam } from '@/components/ui/border-beam' export function HeroSection() { return (
- {/* Premium background grid - softer and atmospheric */} -
- {/* Subtle radial center glow */} -
+ {/* Soft radial centre glow */} +
+ + {/* Bottom fade scrim so hexagons dissolve into the next section */} +
+ +
-
- {/* Clean Pill Badge */} - - - Open Source -
- Contribute on GitHub → - + + {/* CLI Command Pill */} + + + {/* Ultra-sharp Typography */}

- Architect
- your Flutter app. +
+ your{' '} + {' '}app. +

@@ -47,8 +66,8 @@ export function HeroSection() {

{/* Sleek Action Buttons */} -
- - - }> - -
{/* Branching Visual Nodes - Now taking visual priority at the top */}
diff --git a/app/components/landing/MobileNodePattern.tsx b/app/components/landing/MobileNodePattern.tsx index a3ce737..986c7eb 100644 --- a/app/components/landing/MobileNodePattern.tsx +++ b/app/components/landing/MobileNodePattern.tsx @@ -11,9 +11,111 @@ import { WaveIcon } from '@hugeicons/core-free-icons'; import { HugeiconsIcon } from '@hugeicons/react'; +import { motion } from 'motion/react'; import { useState } from 'react'; import Image from "next/image"; +interface NodeSwitchProps { + active: boolean; + onToggle: () => void; + top: string; + left: string; + bgClass: string; + Icon: any; + label: string; +} + +const NodeSwitch = ({ + active, + onToggle, + top, + left, + bgClass, + Icon, + label +}: NodeSwitchProps) => { + return ( +
+
+ {/* Icon container */} +
+ {/* Inactive state background & border */} + + {/* Active state background & border & shadow */} + + + {/* Icon wrapper with subtle scale animation */} + + {label === 'Supabase' ? ( + Supabase + ) : label === 'Firebase' ? ( + Firebase + ) : ( + + )} + +
+ + + {label} + + +
e.stopPropagation()}> + +
+
+
+ ); +}; + export function MobileNodePattern() { const [nodes, setNodes] = useState({ riverpod: true, @@ -28,58 +130,6 @@ export function MobileNodePattern() { setNodes(prev => ({ ...prev, [key]: !prev[key] })); }; - const NodeSwitch = ({ - stateKey, - top, left, - bgClass, - Icon, - label - }: { stateKey: keyof typeof nodes, top: string, left: string, bgClass: string, Icon: any, label: string }) => { - const active = nodes[stateKey]; - return ( -
{ - toggleNode(stateKey); - }} - > -
-
- {label === 'Supabase' ? Supabase : label === 'Firebase' ? Firebase : } -
- - - {label} - - -
e.stopPropagation()}> - toggleNode(stateKey)} - className={`cursor-pointer transition-colors duration-300 ${active ? 'data-[state=checked]:bg-primary' : ''}`} - /> -
-
-
- ); - }; - return (
toggleNode('riverpod')} top="100px" left="80px" bgClass="bg-linear-to-tr from-blue-600 to-blue-400 shadow-blue-500/25" Icon={WaveIcon} label="Riverpod" /> toggleNode('supabase')} top="100px" left="270px" bgClass="bg-linear-to-tr from-emerald-500 to-green-400 shadow-emerald-500/25" Icon={Database01Icon} label="Supabase" /> toggleNode('goRouter')} + top="200px" left="203px" bgClass="bg-linear-to-tr from-rose-500 to-pink-400 shadow-rose-500/25" Icon={Route01Icon} label="GoRouter" @@ -203,21 +256,24 @@ export function MobileNodePattern() { {/* Bottom Nodes */} toggleNode('bloc')} + top="450px" left="200px" bgClass="bg-linear-to-tr from-indigo-500 to-purple-400 shadow-indigo-500/25" Icon={Package01Icon} label="Bloc" /> toggleNode('firebase')} top="550px" left="85px" bgClass="bg-linear-to-tr from-orange-500 to-amber-400 shadow-orange-500/25" Icon={FireIcon} label="Firebase" /> toggleNode('dio')} top="550px" left="280px" bgClass="bg-linear-to-tr from-cyan-500 to-sky-400 shadow-cyan-500/25" Icon={Unlink01Icon} diff --git a/app/components/landing/Navbar.tsx b/app/components/landing/Navbar.tsx new file mode 100644 index 0000000..2db1645 --- /dev/null +++ b/app/components/landing/Navbar.tsx @@ -0,0 +1,276 @@ +'use client' + +import { useState } from 'react' +import Image from 'next/image' +import Link from 'next/link' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' +import { Menu04Icon, Cancel01Icon } from '@hugeicons/core-free-icons' +import { HugeiconsIcon } from '@hugeicons/react' +import { + AnimatePresence, + motion, + useMotionValueEvent, + useScroll, + useTransform, + type MotionValue, +} from 'motion/react' +import { + Highlight, + HighlightItem, +} from '@/components/animate-ui/primitives/effects/highlight' +import { useIsMobile } from '@/hooks/use-mobile' + +const NAV_LINKS = [ + { href: '#how-it-works', label: 'How it Works' }, + { href: '#features', label: 'Features' }, + { href: '#showcase', label: 'Showcase' }, + { href: '#guides', label: 'Guides' }, +] + +const SCROLL_RANGE = 500 + +const easeOut: [number, number, number, number] = [0.16, 1, 0.3, 1] + +export function Navbar({ githubStars }: { githubStars?: React.ReactNode }) { + const [mobileOpen, setMobileOpen] = useState(false) + const [scrolled, setScrolled] = useState(false) + const isMobile = useIsMobile() + + const { scrollY } = useScroll() + + // Track scrolled boolean for mobile menu shape + useMotionValueEvent(scrollY, 'change', (y) => { + setScrolled(y > SCROLL_RANGE * 0.5) + }) + + // ── Derived motion values ─────────────────────────────────────── + + /** Header top padding: 0 → 16px */ + const headerPaddingTop = useTransform(scrollY, [0, SCROLL_RANGE], [0, 16], { clamp: true }) + + /** Nav width: 100vw → 52vw */ + const navWidth = useTransform( + scrollY, + [0, SCROLL_RANGE], + ['90%', '52%'], + { clamp: true }, + ) + + /** Nav corner radius: sharp → pill */ + const navBorderRadius = useTransform(scrollY, [0, SCROLL_RANGE], [0, 9999], { clamp: true }) + + /** Inner horizontal padding: 120px → 20px */ + const navPaddingX = useTransform(scrollY, [0, SCROLL_RANGE], [120, 35], { clamp: true }) + + /** Gap between nav links: 32px → 16px (kept for mobile menu spacing) */ + const linkGap = useTransform(scrollY, [0, SCROLL_RANGE], [32, 16], { clamp: true }) + + /** Bottom border line opacity: 0 → 0 (hidden always, separator removed) */ + const lineOpacity = useTransform(scrollY, [0, SCROLL_RANGE * 0.5], [0, 0], { clamp: true }) + + // ── Glass layer derived values ────────────────────────────────── + + const glassProgress = useTransform(scrollY, [0, SCROLL_RANGE], [0, 1], { clamp: true }) + + /** Blur: 0 → 12px */ + const blurPx = useTransform(scrollY, [0, SCROLL_RANGE], [0, 12], { clamp: true }) + + /** Saturation boost: 100% → 180% */ + const satPct = useTransform(scrollY, [0, SCROLL_RANGE], [100, 180], { clamp: true }) + + /** Border opacity: 0 → 1 */ + const borderAlpha = useTransform(scrollY, [0, SCROLL_RANGE], [0, 1], { clamp: true }) + + /** Drop-shadow opacity: 0 → 0.14 */ + const shadowAlpha = useTransform(scrollY, [0, SCROLL_RANGE], [0, 0.14], { clamp: true }) + + // ── CSS string transforms ─────────────────────────────────────── + + const backdropFilter = useTransform( + [blurPx, satPct] as MotionValue[], + ([b, s]: number[]) => + b > 0.5 ? `blur(${b.toFixed(1)}px) saturate(${s.toFixed(0)}%)` : 'none', + ) + + const backgroundColor = useTransform( + glassProgress, + (v: number) => + v < 0.01 + ? 'transparent' + : `color-mix(in oklab, #ffffff ${(v * 72).toFixed(1)}%, transparent ${(100 - v * 100).toFixed(1)}%)`, + ) + + const borderColor = useTransform( + borderAlpha, + (v: number) => + `color-mix(in oklab, #e4e4e7 ${(v * 100).toFixed(1)}%, transparent)`, + ) + + const boxShadow = useTransform( + shadowAlpha, + (v: number) => + v < 0.005 + ? 'none' + : `0 8px 32px color-mix(in oklab, #000 ${(v * 100).toFixed(1)}%, transparent), 0 2px 8px color-mix(in oklab, #000 ${(v * 50).toFixed(1)}%, transparent)`, + ) + + return ( + + {/* + * The nav shrinks in width and gains a pill border-radius. + * mx-auto centres it as it gets narrower. + */} + + {/* ── Glassmorphism backdrop layer ── */} + + + {/* ── Bottom rule — fades out as nav floats ── */} + + + {/* ── Inner content with animated padding ── */} + + + + + + + {NAV_LINKS.map((link) => ( + + + {link.label} + + + ))} + + +
+ {githubStars ? ( + githubStars + ) : ( + + )} + +
+ + + + + + {/* ── Mobile menu ── */} + + {mobileOpen && ( + + +
+ {githubStars ? ( +
+ {githubStars} +
+ ) : ( + + )} + +
+
+ )} +
+ + ) +} diff --git a/app/components/landing/NodePattern.tsx b/app/components/landing/NodePattern.tsx index 860cb74..6b9d72b 100644 --- a/app/components/landing/NodePattern.tsx +++ b/app/components/landing/NodePattern.tsx @@ -12,8 +12,133 @@ import { } from '@hugeicons/core-free-icons'; import { HugeiconsIcon } from '@hugeicons/react'; import Image from "next/image"; +import { motion } from 'motion/react'; import { useState } from 'react'; +interface NodeSwitchProps { + active: boolean; + onToggle: () => void; + top: string; + left: string; + bgClass: string; + Icon: any; + label: string; +} + +const NodeSwitch = ({ + active, + onToggle, + top, + left, + bgClass, + Icon, + label +}: NodeSwitchProps) => { + return ( +
+ {/* Outer glow ring — visible on hover or when active */} +
+ +
+ {/* Icon container */} +
+ {/* Inactive state background & border & shadow */} + + {/* Active state background & border & shadow */} + + + {/* Icon wrapper with subtle scale animation */} + + {label === 'Supabase' ? ( + Supabase + ) : label === 'Firebase' ? ( + Firebase + ) : ( + +
+ + {/* Label */} + + {label} + + + {/* Switch — stop propagation so click on switch doesn't double-fire */} +
e.stopPropagation()} className="scale-75 origin-center shrink-0"> + +
+
+
+ ); +}; + export function NodePattern() { const [nodes, setNodes] = useState({ riverpod: true, @@ -28,67 +153,8 @@ export function NodePattern() { setNodes(prev => ({ ...prev, [key]: !prev[key] })); }; - const NodeSwitch = ({ - stateKey, - top, left, - bgClass, - Icon, - label - }: { stateKey: keyof typeof nodes, top: string, left: string, bgClass: string, Icon: any, label: string }) => { - const active = nodes[stateKey]; - return ( -
{ - toggleNode(stateKey); - }} - > -
-
- - {label === 'Supabase' ? Supabase : label === 'Firebase' ? Firebase :
- - - {label} - - -
e.stopPropagation()}> - toggleNode(stateKey)} - aria-label={label} - className={`cursor-pointer transition-colors duration-300 ${active ? 'data-[state=checked]:bg-primary' : ''}`} - /> -
-
-
- ); - }; - return ( -
+
-
+
{/* Glossy overlay */}
- +
{/* Shine effect on hover */} @@ -233,42 +299,48 @@ export function NodePattern() { `} toggleNode('goRouter')} top="50%" left="0%" bgClass="bg-linear-to-tr from-rose-500 to-pink-400 shadow-rose-500/25" Icon={Route01Icon} label="GoRouter" /> toggleNode('riverpod')} top="25%" left="22.5%" bgClass="bg-linear-to-tr from-blue-600 to-blue-400 shadow-blue-500/25" Icon={DashboardSquare01Icon} label="Riverpod" /> toggleNode('supabase')} top="70%" left="22.5%" bgClass="bg-linear-to-tr from-emerald-200 to-green-100 shadow-emerald-400/25" Icon={Database01Icon} label="Supabase" /> toggleNode('firebase')} top="30%" left="77.5%" bgClass="bg-linear-to-tr from-orange-200 to-amber-100 shadow-orange-400/25" Icon={FireIcon} label="Firebase" /> toggleNode('bloc')} top="50%" left="100%" bgClass="bg-linear-to-tr from-indigo-500 to-purple-400 shadow-indigo-500/25" Icon={Package01Icon} label="Bloc" /> toggleNode('dio')} top="70%" left="76.25%" bgClass="bg-linear-to-tr from-cyan-500 to-sky-400 shadow-cyan-500/25" Icon={Unlink01Icon} diff --git a/app/components/landing/StatsSection.tsx b/app/components/landing/StatsSection.tsx index 7e30e97..8bddd73 100644 --- a/app/components/landing/StatsSection.tsx +++ b/app/components/landing/StatsSection.tsx @@ -1,5 +1,5 @@ +import { StatsShowcase, StatsShowcaseSkeleton, type StatCard } from "@/app/components/landing/StatsShowcase" import { createPublishableSupabaseClient } from "@/app/lib/supabase/server" -import { StatsShowcase, StatsShowcaseSkeleton } from "@/app/components/landing/StatsShowcase" type StatsResponse = { total_generated?: number @@ -58,51 +58,50 @@ export async function StatsSection() { const topBackendValue = totalGenerated > 0 && topBackend.count > 0 ? toTitleCase(topBackend.key) : "—" - const cards = [ + const cards: StatCard[] = [ { - eyebrow: "Volume", - label: "Projects Generated", + title: "Projects", + eyebrow: "Numbers of projects generated", value: totalGenerated > 0 ? totalGenerated.toLocaleString() : "—", numericValue: totalGenerated > 0 ? totalGenerated : undefined, }, { - eyebrow: "State", - label: "Popular State Management", - value: toTitleCase(stats?.top_state_mgmt), + title: "State Management", + eyebrow: "Most Popular State Management", + value: stats?.top_state_mgmt ? toTitleCase(stats.top_state_mgmt) : "—", }, { + title: "Architecture", eyebrow: "Pattern", - label: "Architecture", - value: toTitleCase(stats?.top_architecture), + value: stats?.top_architecture ? toTitleCase(stats.top_architecture) : "—", }, { - eyebrow: "Backend", - label: "Backend", - value: topBackendValue, + title: "Firebase", + eyebrow: "Usage of Firebase", + value: stats?.be_firebase !== undefined ? `${stats.be_firebase}%` : "—", + numericValue: stats?.be_firebase, }, { - eyebrow: "Supabase", - label: "Supabase", - value: toTitleCase(stats?.be_supabase?.toString()), + title: "Supabase", + eyebrow: "Usage of Supabase", + value: stats?.be_supabase !== undefined ? `${stats.be_supabase}%` : "—", numericValue: stats?.be_supabase, }, { - eyebrow: "Firebase", - label: "Firebase", - value: toTitleCase(stats?.be_firebase?.toString()), - numericValue: stats?.be_firebase, + title: topBackendValue, + eyebrow: "Most Used Backend", + value: topBackendValue, }, { - eyebrow: "Navigation", - label: "Popular Navigation", - value: toTitleCase(stats?.top_navigation), + title: "Navigation", + eyebrow: "Popular Navigation", + value: stats?.top_navigation ? toTitleCase(stats.top_navigation) : "—", }, { - eyebrow: "Theme", - label: "Theme", + title: "Theme", + eyebrow: "Usage of dark theme", value: darkModeRatio !== undefined ? `${darkModeRatio}%` : "—", numericValue: darkModeRatio, - suffix: darkModeRatio !== undefined ? "%" : "", }, ] diff --git a/app/components/landing/StatsShowcase.tsx b/app/components/landing/StatsShowcase.tsx index 350bec6..a19156f 100644 --- a/app/components/landing/StatsShowcase.tsx +++ b/app/components/landing/StatsShowcase.tsx @@ -1,192 +1,232 @@ "use client" -import { - AnalyticsUpIcon, - Boxes, - Database, - Fire, - FlowchartIcon, - Navigation03Icon, - PuzzleIcon, - Sun -} from "@hugeicons/core-free-icons" -import { HugeiconsIcon } from "@hugeicons/react" -import { useEffect, useMemo, useState } from "react" import { Badge } from "@/components/ui/badge" -import { - Card, - CardContent, - CardHeader, - CardTitle -} from "@/components/ui/card" +import { KineticText } from "@/components/ui/kinetic-text" import { Skeleton } from "@/components/ui/skeleton" import { cn } from "@/lib/utils" -import Image from "next/image" -type StatCard = { - label: string +import { BarBreakdown } from "./bento/stats/BarBreakdown" +import { CountStat } from "./bento/stats/CountStat" +import { SparklineStat } from "./bento/stats/SparklineStat" +import { StatCardShell } from "./bento/stats/StatCardShell" + +// ─── types ──────────────────────────────────────────────────────────────────── +export type StatCard = { + title: string value: string numericValue?: number suffix?: string eyebrow: string } +// ─── bento layout ───────────────────────────────────────────────────────────── const bentoClasses = [ - "md:col-span-2 md:row-span-1", - "md:col-span-2 md:row-span-1", - "md:col-span-1 md:row-span-1", - "md:col-span-1 md:row-span-1", - "md:col-span-1 md:row-span-1", - "md:col-span-1 md:row-span-1", - "md:col-span-2 md:row-span-1", + "md:col-span-2 md:row-span-1", // 0 – Projects Generated (wide) + "md:col-span-2 md:row-span-1", // 1 – State Management (wide) + "md:col-span-1 md:row-span-1", // 2 – Architecture (compact) + "md:col-span-1 md:row-span-1", // 3 – Firebase (compact) + "md:col-span-1 md:row-span-1", // 4 – Supabase (compact) + "md:col-span-1 md:row-span-1", // 5 – Backend (compact) + "md:col-span-2 md:row-span-1", // 6 – Navigation (wide) + "md:col-span-2 md:row-span-1", // 7 – Theme (compact) ] -function AnimatedValue({ - value, - numericValue, - suffix = "", -}: Pick) { - const [displayValue, setDisplayValue] = useState(0) - const prefersReducedMotion = useMemo( - () => - typeof window !== "undefined" && - window.matchMedia("(prefers-reduced-motion: reduce)").matches, - [] - ) +// ─── accent definitions ─────────────────────────────────────────────────────── +const ACCENTS = { + volume: { glow: "from-primary/10 to-transparent", chip: "bg-primary/8 border-primary/15", accent: "text-primary", bar: "bg-primary" }, + state: { glow: "from-pink-500/10 to-transparent", chip: "bg-pink-500/8 border-pink-500/15", accent: "text-pink-500", bar: "bg-pink-500" }, + architecture: { glow: "from-rose-500/10 to-transparent", chip: "bg-rose-500/8 border-rose-500/15", accent: "text-rose-500", bar: "bg-rose-500" }, + firebase: { glow: "from-red-500/10 to-transparent", chip: "bg-red-500/8 border-red-500/15", accent: "text-red-500", bar: "bg-red-500" }, + supabase: { glow: "from-emerald-500/10 to-transparent", chip: "bg-emerald-500/8 border-emerald-500/15", accent: "text-emerald-500", bar: "bg-emerald-500" }, + backend: { glow: "from-yellow-500/10 to-transparent", chip: "bg-yellow-500/8 border-yellow-500/15", accent: "text-yellow-500", bar: "bg-yellow-500" }, + navigation: { glow: "from-cyan-500/10 to-transparent", chip: "bg-cyan-500/8 border-cyan-500/15", accent: "text-cyan-500", bar: "bg-cyan-500" }, + theme: { glow: "from-slate-500/10 to-transparent", chip: "bg-slate-500/8 border-slate-500/15", accent: "text-slate-500", bar: "bg-slate-500" }, +} as const - useEffect(() => { - if (numericValue === undefined) return - if (prefersReducedMotion) { - setDisplayValue(numericValue) - return - } +function resolveAccent(label: string) { + const n = label.toLowerCase() + if (n.includes("project")) return ACCENTS.volume + if (n.includes("state")) return ACCENTS.state + if (n.includes("arch")) return ACCENTS.architecture + if (n.includes("firebase")) return ACCENTS.firebase + if (n.includes("supabase")) return ACCENTS.supabase + if (n.includes("backend")) return ACCENTS.backend + if (n.includes("nav")) return ACCENTS.navigation + if (n.includes("theme")) return ACCENTS.theme + return ACCENTS.volume +} - const duration = 1400 - const start = performance.now() - let animationFrame = 0 - const run = (now: number) => { - const progress = Math.min((now - start) / duration, 1) - const eased = 1 - (1 - progress) ** 4 - setDisplayValue(Math.round(eased * numericValue)) - if (progress < 1) animationFrame = requestAnimationFrame(run) - } +// ─── visualization selector ─────────────────────────────────────────────────── +function CardViz({ + index, + card, + isHovered, +}: { index: number; card: StatCard; isHovered: boolean }) { + const a = resolveAccent(card.title) - animationFrame = requestAnimationFrame(run) - return () => cancelAnimationFrame(animationFrame) - }, [numericValue, prefersReducedMotion]) + // Wide cards (0 = projects, 6 = navigation) → sparkline + big stat + if (index === 0) { + return ( + + ) + } - if (numericValue === undefined) return <>{value} + if (index === 6) { + return ( + + ) + } + + // State management (1) → horizontal bar breakdown + if (index === 1) { + return ( + + ) + } + + // Architecture (2) → bar breakdown + if (index === 2) { + return ( + + ) + } + + // Firebase (3), Supabase (4) → ring stat + if (index === 3) { + return ( + + ) + } + + if (index === 4) { + return ( + + ) + } + + // Backend choices (5) → bar breakdown + if (index === 5) { + return ( + + ) + } + + // Fallback — simple big number return ( - <> - {displayValue.toLocaleString()} - {suffix} - +

+ {card.value}{card.suffix} +

) } -export function StatsShowcase({ cards }: { cards: StatCard[] }) { - const accents = { - volume: { - icon: AnalyticsUpIcon, - iconColor: "text-primary", - glow: "from-primary/10 to-transparent", - chip: "bg-primary/8 border-primary/15", - marker: "bg-primary", - }, - state: { - icon: PuzzleIcon, - iconColor: "text-pink-500", - glow: "from-pink-500/10 to-transparent", - chip: "bg-pink-500/8 border-pink-500/15", - marker: "bg-pink-500", - }, - architecture: { - icon: FlowchartIcon, - iconColor: "text-rose-500", - glow: "from-rose-500/10 to-transparent", - chip: "bg-rose-500/8 border-rose-500/15", - marker: "bg-rose-500", - }, - firebase: { - icon: Fire, - iconColor: "text-red-500", - glow: "from-red-500/10 to-transparent", - chip: "bg-red-500/8 border-red-500/15", - marker: "bg-red-500", - }, - supabase: { - icon: Database, - iconColor: "text-green-500", - glow: "from-green-500/10 to-transparent", - chip: "bg-green-500/8 border-green-500/15", - marker: "bg-green-500", - }, - backend: { - icon: Boxes, - iconColor: "text-yellow-500", - glow: "from-yellow-500/10 to-transparent", - chip: "bg-yellow-500/8 border-yellow-500/15", - marker: "bg-yellow-500", - }, - navigation: { - icon: Navigation03Icon, - iconColor: "text-cyan-500", - glow: "from-cyan-500/10 to-transparent", - chip: "bg-cyan-500/8 border-cyan-500/15", - marker: "bg-cyan-500", - }, - theme: { - icon: Sun, - iconColor: "text-slate-500", - glow: "from-slate-500/10 to-transparent", - chip: "bg-slate-500/8 border-slate-500/15", - marker: "bg-slate-500", - }, - default: { - icon: AnalyticsUpIcon, - iconColor: "text-primary", - glow: "from-primary/10 to-transparent", - chip: "bg-primary/8 border-primary/15", - marker: "bg-primary", - }, - } as const +// ─── single bento card ──────────────────────────────────────────────────────── +function StatBentoCard({ card, index }: { card: StatCard; index: number }) { + const a = resolveAccent(card.title) - const resolveAccent = (label: string) => { - const normalized = label.toLowerCase() - if (normalized.includes("project")) return accents.volume - if (normalized.includes("state")) return accents.state - if (normalized.includes("architecture")) return accents.architecture - if (normalized.includes("supabase")) return accents.supabase - if (normalized.includes("firebase")) return accents.firebase - if (normalized.includes("backend")) return accents.backend - if (normalized.includes("navigation")) return accents.navigation - if (normalized.includes("theme")) return accents.theme - return accents.default - } + return ( + + {(isHovered) => } + + ) +} +// ─── section ───────────────────────────────────────────────────────────────── +export function StatsShowcase({ cards }: { cards: StatCard[] }) { return (
+ {/* Heading */}
-

Built smarter with{" "} - - FlutterInit - +

Real setup choices from real projects. See what teams pick most @@ -194,81 +234,18 @@ export function StatsShowcase({ cards }: { cards: StatCard[] }) {

-
- {cards.map((card, index) => { - const accent = resolveAccent(card.label) - const isCompactTile = index === 2 || index === 3 || index === 4 || index === 5 - return ( - -
-
-
- -
-
- {card.label === 'Supabase' ? Supabase : card.label === 'Firebase' ? Firebase : } -
- - {card.label === 'Theme' ? 'Dark Mode' : card.label} - -
- - - -
- - ) - })} + {/* Bento grid */} +
+ {cards.map((card, index) => ( + + ))}
) } +// ─── skeleton ───────────────────────────────────────────────────────────────── export function StatsShowcaseSkeleton() { return (
@@ -278,28 +255,19 @@ export function StatsShowcaseSkeleton() {
-
- {[1, 2, 3, 4, 5, 6].map((id) => ( - + {bentoClasses.map((cls, id) => ( +
- -
- - -
- - -
- - - - - +
+ + +
+ + +
))}
diff --git a/app/components/landing/WhyFlutterInit.tsx b/app/components/landing/WhyFlutterInit.tsx index 3c31c13..53d9800 100644 --- a/app/components/landing/WhyFlutterInit.tsx +++ b/app/components/landing/WhyFlutterInit.tsx @@ -1,13 +1,8 @@ "use client" -import { Badge } from "@/components/ui/badge"; -import { - Card, - CardContent, - CardHeader, - CardTitle -} from "@/components/ui/card"; -import { cn } from '@/lib/utils'; +import { Badge } from "@/components/ui/badge" +import { KineticText } from "@/components/ui/kinetic-text" +import { cn } from "@/lib/utils" import { AiBrain01Icon, Clock01Icon, @@ -17,144 +12,170 @@ import { Globe02Icon, Layers01Icon, Shield01Icon -} from '@hugeicons/core-free-icons'; -import { HugeiconsIcon } from '@hugeicons/react'; +} from "@hugeicons/core-free-icons" +import { FeatureCard } from "./bento/FeatureCard" +import { ArchitecturePreview } from "./bento/previews/ArchitecturePreview" +import { ZeroBoilerplatePreview } from "./bento/previews/ZeroBoilerplatePreview" +import { ProductionReadyPreview } from "./bento/previews/ProductionReadyPreview" +import { PerformancePreview } from "./bento/previews/PerformancePreview" +import { TechStackPreview } from "./bento/previews/TechStackPreview" +import { RapidPrototypingPreview } from "./bento/previews/RapidPrototypingPreview" +import { GlobalReachPreview } from "./bento/previews/GlobalReachPreview" +import { AIReadyPreview } from "./bento/previews/AIReadyPreview" +import { useBentoHover } from "./bento/bento-hover-context" -const bentoClasses = [ - "md:col-span-3 md:row-span-1", // Row 1: Item 1 - "md:col-span-3 md:row-span-1", // Row 1: Item 2 - "md:col-span-2 md:row-span-1", // Row 2: Item 3 - "md:col-span-2 md:row-span-1", // Row 2: Item 4 - "md:col-span-2 md:row-span-1", // Row 2: Item 5 - "md:col-span-2 md:row-span-1", // Row 3: Item 6 - "md:col-span-2 md:row-span-1", // Row 3: Item 7 - "md:col-span-2 md:row-span-1", // Row 3: Item 8 -] +// ─── accent config ──────────────────────────────────────────────────────────── +const accents = { + primary: { iconColor: "text-primary", glow: "from-primary/10 to-transparent", chip: "bg-primary/8 border-primary/15" }, + amber: { iconColor: "text-amber-500", glow: "from-amber-500/10 to-transparent", chip: "bg-amber-500/8 border-amber-500/15" }, + emerald: { iconColor: "text-emerald-500", glow: "from-emerald-500/10 to-transparent", chip: "bg-emerald-500/8 border-emerald-500/15" }, + indigo: { iconColor: "text-indigo-500", glow: "from-indigo-500/10 to-transparent", chip: "bg-indigo-500/8 border-indigo-500/15" }, + blue: { iconColor: "text-blue-500", glow: "from-blue-500/10 to-transparent", chip: "bg-blue-500/8 border-blue-500/15" }, + rose: { iconColor: "text-rose-500", glow: "from-rose-500/10 to-transparent", chip: "bg-rose-500/8 border-rose-500/15" }, + cyan: { iconColor: "text-cyan-500", glow: "from-cyan-500/10 to-transparent", chip: "bg-cyan-500/8 border-cyan-500/15" }, + violet: { iconColor: "text-violet-500", glow: "from-violet-500/10 to-transparent", chip: "bg-violet-500/8 border-violet-500/15" }, +} as const -export function WhyFlutterInit() { - const accents = { - primary: { - iconColor: "text-primary", - glow: "from-primary/10 to-transparent", - chip: "bg-primary/8 border-primary/15", - }, - amber: { - iconColor: "text-amber-500", - glow: "from-amber-500/10 to-transparent", - chip: "bg-amber-500/8 border-amber-500/15", - }, - emerald: { - iconColor: "text-emerald-500", - glow: "from-emerald-500/10 to-transparent", - chip: "bg-emerald-500/8 border-emerald-500/15", - }, - indigo: { - iconColor: "text-indigo-500", - glow: "from-indigo-500/10 to-transparent", - chip: "bg-indigo-500/8 border-indigo-500/15", - }, - blue: { - iconColor: "text-blue-500", - glow: "from-blue-500/10 to-transparent", - chip: "bg-blue-500/8 border-blue-500/15", - }, - rose: { - iconColor: "text-rose-500", - glow: "from-rose-500/10 to-transparent", - chip: "bg-rose-500/8 border-rose-500/15", - }, - cyan: { - iconColor: "text-cyan-500", - glow: "from-cyan-500/10 to-transparent", - chip: "bg-cyan-500/8 border-cyan-500/15", - }, - violet: { - iconColor: "text-violet-500", - glow: "from-violet-500/10 to-transparent", - chip: "bg-violet-500/8 border-violet-500/15", - }, - } as const; +// ─── card meta ──────────────────────────────────────────────────────────────── +const FEATURES = [ + { + title: "Architecture Agnostic", + description: "Clean Architecture, MVVM, or MVC.", + icon: Layers01Icon, + accent: accents.primary, + label: "Workflow", + gridClass: "md:col-span-3 md:row-span-1", + Preview: ArchitecturePreview, + wide: true, + }, + { + title: "Zero Boilerplate", + description: "Skip the 4-hour setup. Focus on building features instead of repetitive configuration.", + icon: FlashIcon, + accent: accents.amber, + label: "Speed", + gridClass: "md:col-span-3 md:row-span-1", + Preview: ZeroBoilerplatePreview, + wide: true, + }, + { + title: "Production Ready", + description: "Enterprise-grade logging and monitoring.", + icon: Shield01Icon, + accent: accents.emerald, + label: "Reliability", + gridClass: "md:col-span-2 md:row-span-1", + Preview: ProductionReadyPreview, + wide: false, + }, + { + title: "Optimized Performance", + description: "Best practices for 60fps apps.", + icon: CpuIcon, + accent: accents.indigo, + label: "Performance", + gridClass: "md:col-span-2 md:row-span-1", + Preview: PerformancePreview, + wide: false, + }, + { + title: "Modern Tech Stack", + description: "Riverpod, Bloc, and design tokens pre-integrated.", + icon: DashboardSquare01Icon, + accent: accents.blue, + label: "Ecosystem", + gridClass: "md:col-span-2 md:row-span-1", + Preview: TechStackPreview, + wide: false, + }, + { + title: "Rapid Prototyping", + description: "From idea to running app in under 60 seconds.", + icon: Clock01Icon, + accent: accents.rose, + label: "Productivity", + gridClass: "md:col-span-2 md:row-span-1", + Preview: RapidPrototypingPreview, + wide: false, + }, + { + title: "Global Reach", + description: "Built-in i18n and localization support.", + icon: Globe02Icon, + accent: accents.cyan, + label: "Localization", + gridClass: "md:col-span-2 md:row-span-1", + Preview: GlobalReachPreview, + wide: false, + }, + { + title: "AI-Ready Context", + description: "AGENTS.md, DESIGN.md, and Cursor rules.", + icon: AiBrain01Icon, + accent: accents.violet, + label: "AI Assistants", + gridClass: "md:col-span-2 md:row-span-1", + Preview: AIReadyPreview, + wide: false, + }, +] as const + +// ─── individual card ───────────────────────────────────────────────────────────── +function FeatureItem({ + title, + description, + accent, + gridClass, + Preview, + wide, +}: (typeof FEATURES)[number]) { + return ( + + {/* Preview visual — fills flex space */} +
+ +
- const features = [ - { - title: "Architecture Agnostic", - description: "Clean Architecture, MVVM, or MVC. FlutterInit adapts to your team's workflow, providing the perfect structure every time.", - icon: Layers01Icon, - accent: accents.primary, - label: "Workflow" - }, - { - title: "Zero Boilerplate", - description: "Skip the 4-hour setup. Focus on building features instead of repetitive configuration.", - icon: FlashIcon, - accent: accents.amber, - label: "Speed" - }, - { - title: "Production Ready", - description: "Enterprise-grade logging and monitoring built into the core.", - icon: Shield01Icon, - accent: accents.emerald, - label: "Reliability" - }, - { - title: "Optimized Performance", - description: "Lightweight Scaffold following best practices for 60fps apps.", - icon: CpuIcon, - accent: accents.indigo, - label: "Performance" - }, - { - title: "Modern Tech Stack", - description: "Riverpod, Bloc, and Material 3 design tokens pre-integrated.", - icon: DashboardSquare01Icon, - accent: accents.blue, - label: "Ecosystem" - }, - { - title: "Rapid Prototyping", - description: "From idea to running app in under 60 seconds.", - icon: Clock01Icon, - accent: accents.rose, - label: "Productivity" - }, - { - title: "Global Reach", - description: "Built-in i18n and localization support to reach users in every language.", - icon: Globe02Icon, - accent: accents.cyan, - label: "Localization" - }, - { - title: "AI-Ready Context", - description: "AGENTS.md, DESIGN.md, and Cursor rules ship with every project.", - icon: AiBrain01Icon, - accent: accents.violet, - label: "AI Assistants" - } - ]; + {/* Title + subtitle at bottom */} +
+

+ {title} +

+

+ {description} +

+
+
+ ) +} +// ─── section ───────────────────────────────────────────────────────────────── +export function WhyFlutterInit() { return (
+ {/* Heading */}
-

Why{" "} - - FlutterInit - {" "} + {" "} exists?

@@ -163,67 +184,13 @@ export function WhyFlutterInit() {

-
- {features.map((feature, index) => { - const isWideTile = index === 0 || index === 1; - return ( - -
-
-
- - -
-
- -
- - {feature.label} - -
- - {feature.title} - -
- -

- {feature.description} -

-
- - ) - })} + {/* Bento grid */} +
+ {FEATURES.map((feature) => ( + + ))}
- ); + ) } - diff --git a/app/components/landing/bento/FeatureCard.tsx b/app/components/landing/bento/FeatureCard.tsx new file mode 100644 index 0000000..9bdc493 --- /dev/null +++ b/app/components/landing/bento/FeatureCard.tsx @@ -0,0 +1,53 @@ +"use client" + +import { cn } from "@/lib/utils" +import { useCallback, useState, type ReactNode } from "react" +import { BentoHoverProvider } from "./bento-hover-context" + +type AccentConfig = { + iconColor: string + glow: string + chip: string +} + +export function FeatureCard({ + children, + className, + accent, +}: { + children: ReactNode + className?: string + accent: AccentConfig +}) { + const [isHovered, setIsHovered] = useState(false) + const [playKey, setPlayKey] = useState(0) + + const requestReplay = useCallback(() => setPlayKey((k) => k + 1), []) + + return ( +
{ setIsHovered(true); setPlayKey((k) => k + 1) }} + onMouseLeave={() => setIsHovered(false)} + > + {/* Grid dot overlay */} +
+ {/* Top glow */} +
+ {/* Gloss */} +
+ + + {children} + +
+ ) +} diff --git a/app/components/landing/bento/bento-hover-context.tsx b/app/components/landing/bento/bento-hover-context.tsx new file mode 100644 index 0000000..cd16ebb --- /dev/null +++ b/app/components/landing/bento/bento-hover-context.tsx @@ -0,0 +1,32 @@ +"use client" + +import { createContext, useCallback, useContext, useState, type ReactNode } from "react" + +type BentoHoverCtx = { + isHovered: boolean + playKey: number + requestReplay: () => void +} + +const BentoHoverContext = createContext({ + isHovered: false, + playKey: 0, + requestReplay: () => {}, +}) + +export function useBentoHover() { + return useContext(BentoHoverContext) +} + +export function BentoHoverProvider({ + children, + isHovered, + playKey, + requestReplay, +}: BentoHoverCtx & { children: ReactNode }) { + return ( + + {children} + + ) +} diff --git a/app/components/landing/bento/previews/AIReadyPreview.tsx b/app/components/landing/bento/previews/AIReadyPreview.tsx new file mode 100644 index 0000000..0464c40 --- /dev/null +++ b/app/components/landing/bento/previews/AIReadyPreview.tsx @@ -0,0 +1,135 @@ +"use client" +// AI-Ready Context preview — file cards for AGENTS.md / DESIGN.md / .cursorrules + +import { cn } from "@/lib/utils" +import { useEffect, useState } from "react" +import { useBentoHover } from "../bento-hover-context" + +const FILES = [ + { + name: "AGENTS.md", + lines: ["# Agent Context", "Project: flutter_starter"], + color: "violet", + }, + { + name: "DESIGN.md", + lines: ["# Design System", "Primary: #6750A4"], + color: "indigo", + }, + { + name: ".cursorrules", + lines: ["Use Riverpod hooks", "Follow clean arch"], + color: "purple", + }, +] + +const colorMap: Record = { + violet: { + border: "border-violet-200", + bg: "bg-violet-50", + title: "text-violet-600", + line: "bg-violet-200", + dot: "bg-violet-400", + }, + indigo: { + border: "border-indigo-200", + bg: "bg-indigo-50", + title: "text-indigo-600", + line: "bg-indigo-200", + dot: "bg-indigo-400", + }, + purple: { + border: "border-purple-200", + bg: "bg-purple-50", + title: "text-purple-600", + line: "bg-purple-200", + dot: "bg-purple-400", + }, +} + +export function AIReadyPreview() { + const { isHovered, playKey } = useBentoHover() + const [visible, setVisible] = useState(0) + const [activeFile, setActiveFile] = useState(-1) + + // 1. Entrance animation: triggers on mount or playKey change + useEffect(() => { + setVisible(0) + const t1 = setTimeout(() => setVisible(1), 100) + const t2 = setTimeout(() => setVisible(2), 200) + const t3 = setTimeout(() => setVisible(3), 300) + + return () => { + clearTimeout(t1) + clearTimeout(t2) + clearTimeout(t3) + } + }, [playKey]) + + // 2. Hover state: handles the active file cycling + useEffect(() => { + if (!isHovered) { + setActiveFile(-1) // Reset when unhovered + return + } + + setActiveFile(0) + const interval = setInterval(() => { + setActiveFile((f) => (f + 1) % FILES.length) + }, 900) + + return () => clearInterval(interval) + }, [isHovered]) + + return ( +
+ {FILES.map((file, i) => { + const c = colorMap[file.color] + const isActive = isHovered && activeFile === i + + return ( +
i ? "opacity-100 translate-x-0" : "opacity-0 translate-x-3", + isActive + ? cn(c.border, c.bg, "scale-[1.02] shadow-sm") + : "border-zinc-200/60 bg-zinc-50/50 grayscale opacity-70 scale-100" + )} + style={{ transitionDelay: visible > i ? `${i * 40}ms` : '0ms' }} + > +
+ + + {file.name} + +
+
+ {file.lines.map((line, j) => ( +
+ ))} +
+
+ ) + })} +
+ ) +} \ No newline at end of file diff --git a/app/components/landing/bento/previews/ArchitecturePreview.tsx b/app/components/landing/bento/previews/ArchitecturePreview.tsx new file mode 100644 index 0000000..4cf892c --- /dev/null +++ b/app/components/landing/bento/previews/ArchitecturePreview.tsx @@ -0,0 +1,71 @@ +"use client" +// Architecture Agnostic preview — animated folder tree + +import { cn } from "@/lib/utils" +import { Folder, File } from "lucide-react" +import { useBentoHover } from "../bento-hover-context" + +const TREE = [ + { indent: 0, label: "lib/", type: "dir" }, + { indent: 1, label: "features/", type: "dir" }, + { indent: 2, label: "auth/", type: "dir" }, + { indent: 3, label: "data/", type: "dir" }, + { indent: 3, label: "domain/", type: "dir" }, + { indent: 3, label: "presentation/", type: "dir" }, + { indent: 2, label: "home/", type: "dir" }, + { indent: 1, label: "core/", type: "dir" }, + { indent: 2, label: "utils.dart", type: "file" }, +] + +export function ArchitecturePreview() { + const { isHovered } = useBentoHover() + + return ( +
+ {TREE.map((node, i) => { + const delayStyle = { + transitionDelay: isHovered ? `${i * 30}ms` : "0ms", + } + + return ( +
+ + {node.type === "dir" ? ( + + ) : ( + + )} + + + {node.label} + +
+ ) + })} +
+ ) +} diff --git a/app/components/landing/bento/previews/GlobalReachPreview.tsx b/app/components/landing/bento/previews/GlobalReachPreview.tsx new file mode 100644 index 0000000..bdc7426 --- /dev/null +++ b/app/components/landing/bento/previews/GlobalReachPreview.tsx @@ -0,0 +1,231 @@ +"use client" +// Global Reach preview — a Flutter-style app frame where locale cycles +// through languages on hover. Strings fade-swap, direction flips for RTL. +// Greyscale idle → full colour on hover, resets on mouse-leave. + +import { cn } from "@/lib/utils" +import { useEffect, useRef, useState, useCallback } from "react" +import { motion, AnimatePresence } from "motion/react" +import { useBentoHover } from "../bento-hover-context" + +// ── Locale data ─────────────────────────────────────────────────────────────── +interface Locale { + flag: string + code: string + greeting: string + subtitle: string + cta: string + arb: string + dir: "ltr" | "rtl" +} + +const LOCALES: Locale[] = [ + { + flag: "🇺🇸", code: "EN", + greeting: "Welcome back", + subtitle: "Your projects are ready", + cta: "Get started", + arb: '"Welcome back"', + dir: "ltr", + }, + { + flag: "🇩🇪", code: "DE", + greeting: "Willkommen zurück", + subtitle: "Ihre Projekte sind bereit", + cta: "Loslegen", + arb: '"Willkommen zurück"', + dir: "ltr", + }, + { + flag: "🇯🇵", code: "JA", + greeting: "おかえりなさい", + subtitle: "プロジェクトの準備ができています", + cta: "始める", + arb: '"おかえりなさい"', + dir: "ltr", + }, + { + flag: "🇸🇦", code: "AR", + greeting: "مرحباً بعودتك", + subtitle: "مشاريعك جاهزة", + cta: "ابدأ الآن", + arb: '"مرحباً بعودتك"', + dir: "rtl", + }, + { + flag: "🇫🇷", code: "FR", + greeting: "Bon retour", + subtitle: "Vos projets sont prêts", + cta: "Commencer", + arb: '"Bon retour"', + dir: "ltr", + }, +] + +const CYCLE_INTERVAL = 1800 // ms between locale switches + +// ── Fading string — animates out → swaps content → animates in ─────────────── +function FadingString({ + value, + className, + dir, +}: { + value: string + className?: string + dir?: "ltr" | "rtl" +}) { + return ( + + + {value} + + + ) +} + +// ── Main component ───────────────────────────────────────────────────────────── +export function GlobalReachPreview() { + const { isHovered, playKey } = useBentoHover() + const [localeIdx, setLocaleIdx] = useState(0) + const cycleRef = useRef(null) + + const locale = LOCALES[localeIdx] + + const stopCycle = useCallback(() => { + if (cycleRef.current) clearInterval(cycleRef.current) + }, []) + + const startCycle = useCallback(() => { + stopCycle() + setLocaleIdx(0) + cycleRef.current = setInterval(() => { + setLocaleIdx((i) => (i + 1) % LOCALES.length) + }, CYCLE_INTERVAL) + }, [stopCycle]) + + useEffect(() => { + if (!isHovered) { + stopCycle() + setLocaleIdx(0) + return + } + startCycle() + return stopCycle + }, [isHovered, playKey, startCycle, stopCycle]) + + return ( +
+ + {/* Body */} +
+ {/* Greeting string */} + + + {/* Subtitle string */} + + + {/* CTA button */} +
+ + + +
+
+ + {/* ── ARB source line ── */} +
+ + "homeScreen.greeting" + + : + + + +
+ +
+ ) +} + +// ── String row sub-component ────────────────────────────────────────────────── +function StringRow({ + arbKey, + value, + dir, + isHovered, +}: { + arbKey: string + value: string + dir: "ltr" | "rtl" + isHovered: boolean +}) { + return ( +
+ + {arbKey} + + + + +
+ ) +} \ No newline at end of file diff --git a/app/components/landing/bento/previews/PerformancePreview.tsx b/app/components/landing/bento/previews/PerformancePreview.tsx new file mode 100644 index 0000000..edc99ca --- /dev/null +++ b/app/components/landing/bento/previews/PerformancePreview.tsx @@ -0,0 +1,232 @@ +"use client" +// Performance preview — Flutter DevTools-style frame budget timeline +// Bars start janky (over budget, rose), hover cascades them to silky (indigo) + +import { cn } from "@/lib/utils" +import { useEffect, useRef, useState } from "react" +import { motion, AnimatePresence } from "motion/react" +import { useBentoHover } from "../bento-hover-context" + +// ─── frame data ────────────────────────────────────────────────────────────── +// Each entry: [jank_ms, smooth_ms] +const FRAMES: [number, number][] = [ + [11, 10], + [30, 14], // ← jank + [13, 12], + [34, 13], // ← jank + [12, 11], + [27, 15], // ← jank + [15, 14], + [37, 12], // ← jank + [13, 13], + [16, 15], + [14, 14], + [16, 13], +] + +const BUDGET_MS = 16.7 +const MAX_MS = 40 // tallest displayable value +const BAR_H = 46 // px height of bar area + +const toH = (ms: number) => Math.max(3, (ms / MAX_MS) * BAR_H) + +const JANK_INDICES = FRAMES + .map(([j], i) => (j > BUDGET_MS ? i : -1)) + .filter((i) => i !== -1) + +// ─── live avg counter (interpolates between old/new) ───────────────────────── +function useAnimatedNumber(value: number, decimals = 1) { + const [display, setDisplay] = useState(value) + const fromRef = useRef(value) + const rafRef = useRef(null) + + useEffect(() => { + const from = fromRef.current + const to = value + if (from === to) return + const start = performance.now() + const dur = 500 + const tick = (now: number) => { + const t = Math.min((now - start) / dur, 1) + const ease = 1 - Math.pow(1 - t, 3) + setDisplay(parseFloat((from + (to - from) * ease).toFixed(decimals))) + if (t < 1) rafRef.current = requestAnimationFrame(tick) + else fromRef.current = to + } + rafRef.current = requestAnimationFrame(tick) + return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current) } + }, [value, decimals]) + + return display +} + +// ─── component ──────────────────────────────────────────────────────────────── +export function PerformancePreview() { + const { isHovered, playKey } = useBentoHover() + // which bar indices have been "optimized" this play + const [done, setDone] = useState>(new Set()) + const timers = useRef([]) + + useEffect(() => { + timers.current.forEach(clearTimeout) + timers.current = [] + setDone(new Set()) + if (!isHovered) return + + // cascade: every bar optimizes with a small stagger + FRAMES.forEach((_, i) => { + timers.current.push( + setTimeout(() => setDone((prev) => new Set([...prev, i])), 60 + i * 90) + ) + }) + return () => timers.current.forEach(clearTimeout) + }, [isHovered, playKey]) + + const allDone = done.size === FRAMES.length + const jankLeft = JANK_INDICES.filter((i) => !done.has(i)).length + const rawAvg = FRAMES.reduce((s, [j, o], i) => s + (done.has(i) ? o : j), 0) / FRAMES.length + const avgMs = useAnimatedNumber(rawAvg, 1) + + const budgetFromBottom = (BUDGET_MS / MAX_MS) * BAR_H // px from bottom + + return ( +
+ + {/* ── header row ── */} +
+ + UI Thread · Frame Budget + + + + {!isHovered ? ( + + Hover to run + + ) : allDone ? ( + + ✦ 60fps Unlocked + + ) : ( + + ● Optimizing… + + )} + +
+ + {/* ── frame bars ── */} +
+ + {/* budget line */} +
+
+ + 16.7 ms + +
+ + {/* bars */} + {FRAMES.map(([jankMs, smoothMs], i) => { + const isOpt = done.has(i) + const isJank = jankMs > BUDGET_MS + const targetH = toH(isOpt ? smoothMs : jankMs) + + return ( + + {/* gradient fill */} + + {/* shine on optimized bars */} + {isOpt && ( + + )} + + ) + })} +
+ + {/* ── stats row ── */} +
+ {/* avg */} + + avg{" "} + + {avgMs.toFixed(1)} ms + + + + · + + {/* jank count */} + + + {jankLeft === 0 + ? "0 jank frames ✓" + : `${jankLeft} jank frame${jankLeft > 1 ? "s" : ""}`} + + +
+ +
+ ) +} diff --git a/app/components/landing/bento/previews/ProductionReadyPreview.tsx b/app/components/landing/bento/previews/ProductionReadyPreview.tsx new file mode 100644 index 0000000..fefc4f9 --- /dev/null +++ b/app/components/landing/bento/previews/ProductionReadyPreview.tsx @@ -0,0 +1,98 @@ +"use client" +// Production Ready preview — Flutter runtime logger stream + +import { cn } from "@/lib/utils" +import { useEffect, useRef, useState } from "react" +import { AnimatePresence, motion } from "motion/react" +import { useBentoHover } from "../bento-hover-context" + +interface LogEntry { + time: string + type: "API" | "DB" | "ERR" | "RVP" | "BLC" + msg: string +} + +const LOG_ENTRIES: LogEntry[] = [ + { time: "09:12:01", type: "RVP", msg: "authProvider: Initialized" }, + { time: "09:12:02", type: "DB", msg: "Isar: opened users collection" }, + { time: "09:12:03", type: "API", msg: "GET /api/v1/profile → 200 (142ms)" }, + { time: "09:12:04", type: "RVP", msg: "userProvider: State → AsyncData" }, + { time: "09:12:05", type: "BLC", msg: "AuthBloc: LoggedOut → Authenticated" }, + { time: "09:12:06", type: "API", msg: "POST /api/v1/posts → 201 (89ms)" }, + { time: "09:12:07", type: "DB", msg: "Isar: inserted 1 record (posts)" }, + { time: "09:12:08", type: "ERR", msg: "DioException: 401 Unauthorized" }, + { time: "09:12:09", type: "API", msg: "POST /auth/refresh → 200 (56ms)" }, + { time: "09:12:10", type: "BLC", msg: "TokenBloc: Refreshed → Active" }, +] + +const TYPE_STYLES: Record = { + API: { badge: "text-sky-500 bg-sky-500/10", text: "text-zinc-600" }, + DB: { badge: "text-amber-500 bg-amber-500/10", text: "text-zinc-600" }, + ERR: { badge: "text-rose-500 bg-rose-500/10", text: "text-rose-500 font-semibold" }, + RVP: { badge: "text-cyan-500 bg-cyan-500/10", text: "text-zinc-600" }, + BLC: { badge: "text-violet-500 bg-violet-500/10", text: "text-zinc-600" }, +} + +const VISIBLE = 3 + +export function ProductionReadyPreview() { + const { isHovered } = useBentoHover() + // step = the starting index of the visible 3-row window + const [step, setStep] = useState(0) + const stepRef = useRef(0) + + useEffect(() => { + if (!isHovered) return + + const id = setInterval(() => { + stepRef.current = (stepRef.current + 1) % LOG_ENTRIES.length + setStep(stepRef.current) + }, 700) + + return () => clearInterval(id) + }, [isHovered]) + + const visibleEntries = Array.from({ length: VISIBLE }, (_, i) => { + const idx = (step + i) % LOG_ENTRIES.length + return { idx, log: LOG_ENTRIES[idx] } + }) + + return ( +
+ + {visibleEntries.map(({ idx, log }) => { + const s = TYPE_STYLES[log.type] + return ( + + + {log.time} + + + {log.type} + + + {log.msg} + + + ) + })} + +
+ ) +} diff --git a/app/components/landing/bento/previews/RapidPrototypingPreview.tsx b/app/components/landing/bento/previews/RapidPrototypingPreview.tsx new file mode 100644 index 0000000..9892d43 --- /dev/null +++ b/app/components/landing/bento/previews/RapidPrototypingPreview.tsx @@ -0,0 +1,193 @@ +"use client" +// Rapid Prototyping preview — CLI output streams line-by-line on hover. +// Lines are NEVER removed. Hover = color, no hover = greyscale. + +import { cn } from "@/lib/utils" +import { useEffect, useRef, useState } from "react" +import { AnimatePresence, motion } from "motion/react" +import { useBentoHover } from "../bento-hover-context" + +// ── CLI output lines ────────────────────────────────────────────────────────── +interface Line { + text: string + type: "cmd" | "info" | "file" | "done" | "warn" | "pkg" +} + +const LINES: Line[] = [ + { text: "npx create-flutterinit my_app", type: "cmd" }, + { text: "Need to install: create-flutterinit", type: "info" }, + { text: "✦ Fetching latest template…", type: "info" }, + { text: "✦ Resolving dependencies…", type: "info" }, + { text: "pkg flutter_riverpod ^2.5.1", type: "pkg" }, + { text: "pkg go_router ^14.2.0", type: "pkg" }, + { text: "pkg dio ^5.4.3", type: "pkg" }, + { text: "pkg hive_flutter ^1.1.0", type: "pkg" }, + { text: "file lib/main.dart", type: "file" }, + { text: "file lib/core/router.dart", type: "file" }, + { text: "file lib/core/di/injector.dart", type: "file" }, + { text: "file lib/features/auth/view.dart", type: "file" }, + { text: "file lib/features/home/view.dart", type: "file" }, + { text: "file lib/shared/widgets/", type: "file" }, + { text: "file pubspec.yaml", type: "file" }, + { text: "warn Running `flutter pub get`…", type: "warn" }, + { text: "✓ Packages synced (47 packages)", type: "done" }, + { text: "✓ Ready in 2.1 s — cd my_app && code .", type: "done" }, +] + +// ms delay before each line appears during the hover animation +const DELAYS = [0, 300, 550, 800, 1050, 1200, 1350, 1500, 1700, 1850, 2000, 2150, 2300, 2450, 2600, 2800, 3100, 3400] + +const LINE_STYLE: Record = { + cmd: { active: "text-violet-500 font-semibold", grey: "text-zinc-500 font-semibold" }, + info: { active: "text-zinc-400 italic", grey: "text-zinc-600 italic" }, + file: { active: "text-sky-500", grey: "text-zinc-600" }, + pkg: { active: "text-amber-400", grey: "text-zinc-600" }, + warn: { active: "text-yellow-500 italic", grey: "text-zinc-600 italic" }, + done: { active: "text-emerald-500 font-bold", grey: "text-zinc-500 font-bold" }, +} + +const LINE_PREFIX: Record = { + cmd: "❯", + info: null, + file: "·", + pkg: "↓", + warn: "⚠", + done: null, +} + +// ── typewriter for the command line ─────────────────────────────────────────── +function useTypewriter(text: string, active: boolean, msPerChar = 36) { + const [displayed, setDisplayed] = useState("") + useEffect(() => { + setDisplayed("") + if (!active) return + let i = 0 + const id = setInterval(() => { + i++ + setDisplayed(text.slice(0, i)) + if (i >= text.length) clearInterval(id) + }, msPerChar) + return () => clearInterval(id) + }, [active, text]) + return displayed +} + +// ── constants ───────────────────────────────────────────────────────────────── +const VISIBLE = 5 // rows shown in the sliding window while streaming +const ROW_H = 26 + +// ── main component ──────────────────────────────────────────────────────────── +export function RapidPrototypingPreview() { + const { isHovered, playKey } = useBentoHover() + const [revealedCount, setRevealedCount] = useState(LINES.length) // show all on first render + const timers = useRef([]) + + const cmdTyped = useTypewriter(LINES[0].text, isHovered && revealedCount >= 1) + + useEffect(() => { + timers.current.forEach(clearTimeout) + timers.current = [] + + if (!isHovered) { + // Don't reset revealedCount — keep lines visible in greyscale + return + } + + // Reset and replay the stream animation on each hover-in + setRevealedCount(0) + LINES.forEach((_, i) => { + timers.current.push( + setTimeout(() => setRevealedCount(i + 1), DELAYS[i]), + ) + }) + return () => timers.current.forEach(clearTimeout) + }, [isHovered, playKey]) + + // Cmd line (index 0) is always pinned at the top — never enters the sliding window. + // The window covers indices 1..N, showing the last (VISIBLE - 1) of them. + const REST = LINES.slice(1) + const shown = Math.max(0, revealedCount - 1) // how many of REST are revealed + const wStart = Math.max(0, shown - (VISIBLE - 1)) + const windowLines = REST.slice(wStart, shown).map((line, i) => ({ + line, + globalIdx: wStart + i + 1, // +1 because cmd is index 0 + })) + + const cmdLine = LINES[0] + const cmdStyles = LINE_STYLE["cmd"] + const cmdVisible = revealedCount >= 1 + + return ( +
+ {/* ── Pinned cmd line — always visible, never scrolls away ── */} + + + + {isHovered ? cmdTyped : cmdLine.text} + {isHovered && cmdTyped.length < cmdLine.text.length && ( + + )} + + + + {/* ── Sliding window for the rest of the lines ── */} + + {windowLines.map(({ line, globalIdx }) => { + const styles = LINE_STYLE[line.type] + const prefix = LINE_PREFIX[line.type] + + const textClass = cn( + "text-[11px] truncate transition-colors duration-500", + isHovered ? styles.active : styles.grey, + ) + const prefixClass = cn( + "shrink-0 text-[10px] transition-colors duration-500", + isHovered ? "text-zinc-400" : "text-zinc-600", + ) + + // All lines slide in from the left with a fade. + // x distance varies by type to give each line a distinct weight. + const T = { + cmd: { x: -14, dur: 0.28, ease: [0.2, 0, 0.2, 1] as const }, + info: { x: -10, dur: 0.22, ease: [0.4, 0, 0.6, 1] as const }, + file: { x: -8, dur: 0.14, ease: [0.2, 0, 0.4, 1] as const }, + pkg: { x: -8, dur: 0.14, ease: [0.2, 0, 0.4, 1] as const }, + warn: { x: -12, dur: 0.20, ease: [0.4, 0, 0.2, 1] as const }, + done: { x: -10, dur: 0.32, ease: [0, 0, 0.2, 1] as const }, + }[line.type] + + return ( + + {prefix && ( + {prefix} + )} + + {line.text} + + ) + })} + +
+ ) +} \ No newline at end of file diff --git a/app/components/landing/bento/previews/TechStackPreview.tsx b/app/components/landing/bento/previews/TechStackPreview.tsx new file mode 100644 index 0000000..31291e4 --- /dev/null +++ b/app/components/landing/bento/previews/TechStackPreview.tsx @@ -0,0 +1,157 @@ +"use client" +// Tech Stack preview — dual marquee rows + spring badge pop-in on hover + +import { cn } from "@/lib/utils" +import { useEffect, useRef, useState } from "react" +import { motion, AnimatePresence, useAnimationFrame } from "motion/react" +import { useBentoHover } from "../bento-hover-context" + +// ── package data ────────────────────────────────────────────────────────────── +interface Pkg { + label: string + dot: string // tailwind bg-* dot colour (coloured state) + badge: string // tailwind classes for the coloured pill +} + +const ROW_A: Pkg[] = [ + { label: "riverpod", dot: "bg-sky-400", badge: "bg-sky-50 text-sky-600 border-sky-200" }, + { label: "bloc", dot: "bg-violet-400", badge: "bg-violet-50 text-violet-600 border-violet-200" }, + { label: "go_router", dot: "bg-cyan-400", badge: "bg-cyan-50 text-cyan-600 border-cyan-200" }, + { label: "get_it", dot: "bg-amber-400", badge: "bg-amber-50 text-amber-600 border-amber-200" }, + { label: "dio", dot: "bg-orange-400", badge: "bg-orange-50 text-orange-600 border-orange-200" }, +] + +const ROW_B: Pkg[] = [ + { label: "supabase", dot: "bg-emerald-400", badge: "bg-emerald-50 text-emerald-600 border-emerald-200" }, + { label: "hive", dot: "bg-yellow-400", badge: "bg-yellow-50 text-yellow-600 border-yellow-200" }, + { label: "freezed", dot: "bg-indigo-400", badge: "bg-indigo-50 text-indigo-600 border-indigo-200" }, + { label: "json_serial.", dot: "bg-teal-400", badge: "bg-teal-50 text-teal-600 border-teal-200" }, + { label: "flutter_hooks", dot: "bg-pink-400", badge: "bg-pink-50 text-pink-600 border-pink-200" }, +] + +// ── greyscale fallback pill ─────────────────────────────────────────────────── +const GREY_BADGE = "bg-zinc-100 text-zinc-400 border-zinc-200" +const GREY_DOT = "bg-zinc-300" + +// ── scrolling track ──────────────────────────────────────────────────────────── +function MarqueeRow({ + pkgs, + direction, + speed, + isHovered, + activePkgs, +}: { + pkgs: Pkg[] + direction: "left" | "right" + speed: number // px per second (when hovered) + isHovered: boolean + activePkgs: Set +}) { + const trackRef = useRef(null) + const xRef = useRef(0) + const isHoveredRef = useRef(isHovered) + + // keep ref in sync without re-creating the frame callback + useEffect(() => { isHoveredRef.current = isHovered }, [isHovered]) + + const items = [...pkgs, ...pkgs, ...pkgs] + + useAnimationFrame((_, delta) => { + if (!trackRef.current || !isHoveredRef.current) return // ← stop when idle + + const step = (speed * delta) / 1000 + xRef.current += direction === "left" ? -step : step + + const trackW = trackRef.current.scrollWidth / 3 + if (direction === "left" && xRef.current < -trackW) xRef.current += trackW + if (direction === "right" && xRef.current > 0) xRef.current -= trackW + + trackRef.current.style.transform = `translateX(${xRef.current}px)` + }) + + return ( +
+
+ {items.map((pkg, i) => { + const active = isHovered && activePkgs.has(pkg.label) + const coloured = isHovered // show colour only while hovered + + return ( + + + {pkg.label} + + ) + })} +
+
+ ) +} + +// ── main component ───────────────────────────────────────────────────────────── +const ALL_PKGS = [...ROW_A, ...ROW_B] + +export function TechStackPreview() { + const { isHovered, playKey } = useBentoHover() + const [activePkgs, setActivePkgs] = useState>(new Set()) + const timers = useRef([]) + + // stagger-reveal each badge on hover + useEffect(() => { + timers.current.forEach(clearTimeout) + timers.current = [] + setActivePkgs(new Set()) + if (!isHovered) return + + ALL_PKGS.forEach((pkg, i) => { + timers.current.push( + setTimeout( + () => setActivePkgs((prev) => new Set([...prev, pkg.label])), + 80 + i * 90, + ), + ) + }) + return () => timers.current.forEach(clearTimeout) + }, [isHovered, playKey]) + + return ( +
+ + {/* ── row A — scrolls left ── */} + + + {/* ── row B — scrolls right ── */} + + +
+ ) +} diff --git a/app/components/landing/bento/previews/ZeroBoilerplatePreview.tsx b/app/components/landing/bento/previews/ZeroBoilerplatePreview.tsx new file mode 100644 index 0000000..a2366aa --- /dev/null +++ b/app/components/landing/bento/previews/ZeroBoilerplatePreview.tsx @@ -0,0 +1,69 @@ +"use client" +// Zero Boilerplate preview — time saved ticker + +import { cn } from "@/lib/utils" +import { useEffect, useState } from "react" +import { useBentoHover } from "../bento-hover-context" +import { HugeiconsIcon } from "@hugeicons/react" +import { Rocket01Icon, Tick02Icon } from "@hugeicons/core-free-icons" + +const STEPS = [ + { label: "Setup project structure", done: false }, + { label: "Configure routing", done: false }, + { label: "Add state management", done: false }, + { label: "Wire up DI", done: false }, + { label: "Write boilerplate", done: false }, +] + +export function ZeroBoilerplatePreview() { + const { isHovered, playKey } = useBentoHover() + const [checked, setChecked] = useState(0) + + useEffect(() => { + setChecked(0) + if (!isHovered) return + const timers: NodeJS.Timeout[] = [] + STEPS.forEach((_, i) => { + timers.push(setTimeout(() => setChecked(i + 1), 220 + i * 260)) + }) + return () => timers.forEach(clearTimeout) + }, [isHovered, playKey]) + + return ( +
+ {STEPS.map((step, i) => ( +
+ i + ? "border-amber-500 bg-amber-500 text-white scale-110" + : isHovered + ? "border-amber-300 text-amber-400" + : "border-zinc-200 text-zinc-300" + )}> + {checked > i && ( + + )} + + i + ? "text-zinc-400 line-through" + : isHovered ? "text-zinc-600" : "text-zinc-400" + )}> + {step.label} + +
+ ))} +
+ + Done in <60s +
+
+ ) +} diff --git a/app/components/landing/bento/stats/BarBreakdown.tsx b/app/components/landing/bento/stats/BarBreakdown.tsx new file mode 100644 index 0000000..4a0ed5a --- /dev/null +++ b/app/components/landing/bento/stats/BarBreakdown.tsx @@ -0,0 +1,59 @@ +"use client" +// Horizontal bar chart breakdown for compact stat cells + +import { cn } from "@/lib/utils" +import { useEffect, useState } from "react" + +type BarBreakdownItem = { + label: string + percent: number + colorClass: string +} + +export function BarBreakdown({ + eyebrow, + items, + isHovered, + accentClass, +}: { + eyebrow: string + items: BarBreakdownItem[] + isHovered: boolean + accentClass: string +}) { + const [animated, setAnimated] = useState(false) + + useEffect(() => { + if (!isHovered) { setAnimated(false); return } + const t = setTimeout(() => setAnimated(true), 100) + return () => clearTimeout(t) + }, [isHovered]) + + return ( +
+
+ {items.map((item, i) => ( +
+
+ + {item.label} + + + {item.percent}% + +
+
+
+
+
+ ))} +
+
+ ) +} diff --git a/app/components/landing/bento/stats/CountStat.tsx b/app/components/landing/bento/stats/CountStat.tsx new file mode 100644 index 0000000..3428300 --- /dev/null +++ b/app/components/landing/bento/stats/CountStat.tsx @@ -0,0 +1,33 @@ +"use client" +import { CountingNumber } from "@/components/animate-ui/primitives/texts/counting-number" +// Donut / ring stat for compact single-metric cells + +import { cn } from "@/lib/utils" + +export function CountStat({ + value, + numericValue, + suffix = "", + percent, + color, + trackColor, + isHovered, + accentClass, +}: { + eyebrow: string + value: string + numericValue?: number + suffix?: string + percent: number + color: string + trackColor: string + isHovered: boolean + accentClass: string +}) { + + return ( +
+ +
+ ) +} diff --git a/app/components/landing/bento/stats/SparklineStat.tsx b/app/components/landing/bento/stats/SparklineStat.tsx new file mode 100644 index 0000000..858b123 --- /dev/null +++ b/app/components/landing/bento/stats/SparklineStat.tsx @@ -0,0 +1,164 @@ +"use client" +import { CountingNumber } from "@/components/animate-ui/primitives/texts/counting-number" +// Sparkline + big stat for wide cards (Projects Generated, State Management) + +import { cn } from "@/lib/utils" +import { useEffect, useState } from "react" + +type SparklineStatProps = { + value: string + numericValue?: number + suffix?: string + eyebrow: string + trend?: number[] // spark bars (0-100) + accentClass: string + barClass: string // explicit bg-* class for the bars + isHovered: boolean +} + +function AnimatedValue({ + value, + numericValue, + suffix = "", + isHovered, + accentClass, +}: Pick) { + const [display, setDisplay] = useState(0) + + useEffect(() => { + if (numericValue === undefined) return + if (!isHovered) { setDisplay(0); return } + const duration = 1200 + const start = performance.now() + let raf = 0 + const run = (now: number) => { + const p = Math.min((now - start) / duration, 1) + const eased = 1 - (1 - p) ** 4 + setDisplay(Math.round(eased * numericValue)) + if (p < 1) raf = requestAnimationFrame(run) + } + raf = requestAnimationFrame(run) + return () => cancelAnimationFrame(raf) + }, [numericValue, isHovered]) + + return <>{display.toLocaleString()}{suffix} +} + +export function SparklineStat({ + value, + numericValue, + suffix = "", + trend = [30, 45, 38, 60, 72, 65, 85, 90, 95, 100], + barClass, + isHovered, +}: SparklineStatProps) { + const [hoveredBar, setHoveredBar] = useState(null) + + // Resolve color variables based on the barClass + const isPrimary = barClass.includes("primary") + const colorValue = isPrimary ? "var(--primary, #3b82f6)" : "var(--color-cyan-500, #06b6d4)" + + const getTooltipValue = (h: number) => { + if (numericValue !== undefined) { + const maxTrend = Math.max(...trend) + const ratio = numericValue / (maxTrend || 1) + const calculated = Math.round(h * ratio) + return `${calculated.toLocaleString()}${suffix}` + } + return `${h}%` + } + + return ( +
+

+ {numericValue !== undefined ? ( + <> + + {suffix} + + ) : ( + value + )} +

+ + {/* Sparkline Container */} +
setHoveredBar(null)} + > + {/* Subtle grid lines in background */} +
+
+
+
+
+ + {trend.map((h, i) => { + const isBarHovered = hoveredBar === i + // Scale slightly on hover for 3D realism + const scale = isBarHovered ? "scale-y-[1.08] scale-x-[1.04]" : "scale-y-100 scale-x-100" + + return ( +
setHoveredBar(i)} + > + {/* Tooltip */} +
+
+ {getTooltipValue(h)} +
+ {/* Arrow */} +
+
+ + {/* Spark bar */} +
+ {/* Highlight/glowing cap at the top edge of the bar */} + {isHovered && ( +
+ )} +
+
+ ) + })} +
+
+ ) +} diff --git a/app/components/landing/bento/stats/StatCardShell.tsx b/app/components/landing/bento/stats/StatCardShell.tsx new file mode 100644 index 0000000..3ced692 --- /dev/null +++ b/app/components/landing/bento/stats/StatCardShell.tsx @@ -0,0 +1,69 @@ +"use client" +// Shared shell for StatsShowcase bento cards + +import { cn } from "@/lib/utils" +import { useState, type ReactNode } from "react" + +type StatCardShellProps = { + children: (isHovered: boolean) => ReactNode + className?: string + glowClass: string // e.g. "from-primary/10 to-transparent" + title: string + subtitle?: string +} + +export function StatCardShell({ + children, + className, + glowClass, + title, + subtitle, +}: StatCardShellProps) { + const [isHovered, setIsHovered] = useState(false) + + return ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + {/* Grid dot overlay */} +
+ {/* Top glow */} +
+ {/* Gloss */} +
+ + {/* Visualization — fills the flex space */} +
+ {children(isHovered)} +
+ + {/* Title + subtitle at bottom */} +
+

+ {title} +

+ {subtitle && ( +

+ {subtitle} +

+ )} +
+
+ ) +} diff --git a/app/page.tsx b/app/page.tsx index 7054a35..528959a 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -2,6 +2,8 @@ import { HeroSection } from "@/app/components/landing/HeroSection" import { WhyFlutterInit } from "@/app/components/landing/WhyFlutterInit" import { Footer } from "@/app/components/landing/Footer" import { StatsSection, StatsSectionSkeleton } from "@/app/components/landing/StatsSection" +import { Navbar } from "@/app/components/landing/Navbar" +import { GitHubStars, GitHubStarsSkeleton } from "@/app/components/landing/GitHubStars" import { Suspense } from "react" // Re-render this page (and re-fetch stats from Supabase) at most every 60 seconds. @@ -12,6 +14,13 @@ export const revalidate = 60 export default function Page() { return (
+ }> + + + } + /> }> diff --git a/bun.lock b/bun.lock index 0fbb21a..451993a 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,7 @@ "@hugeicons/react": "^1.1.6", "@supabase/supabase-js": "^2.105.1", "@vercel/analytics": "^2.0.1", + "border-beam": "^1.2.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -17,6 +18,8 @@ "handlebars": "^4.7.8", "input-otp": "^1.4.2", "jszip": "^3.10.1", + "lucide-react": "^1.18.0", + "motion": "^12.40.0", "next": "16.1.4", "next-themes": "^0.4.6", "radix-ui": "^1.4.3", @@ -745,6 +748,8 @@ "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], + "border-beam": ["border-beam@1.2.0", "", { "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-WDcuwqVgd0SqIw1VQhXoSzXS4r3YhGBdd1pb+RqDmBe7+umydR9GrnazJkRs+7ngQV/PSP/4lZHhwu36DE2+bQ=="], + "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, ""], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, ""], @@ -1031,6 +1036,8 @@ "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + "framer-motion": ["framer-motion@12.40.0", "", { "dependencies": { "motion-dom": "^12.40.0", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg=="], + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], "fs-extra": ["fs-extra@11.3.3", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg=="], @@ -1297,6 +1304,8 @@ "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, ""], + "lucide-react": ["lucide-react@1.21.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-reEZMXq8Qdd5jg5XYkQ5TR1fB/GiQ7ih4vcrthYDtgjSDwh0i6/YLiGjsWsIwgN49gpAnd4J2elSNzncMEEUUQ=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, ""], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, ""], @@ -1323,6 +1332,12 @@ "minimist": ["minimist@1.2.8", "", {}, ""], + "motion": ["motion@12.40.0", "", { "dependencies": { "framer-motion": "^12.40.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-yjrHUrBFW6kQvjJwRsoiPSAhC5tRwRqNGJWmiJ4CrGnbKp0V88AdzkhBmDoqIsIPfarOe0Uddd37Xq43/gIocA=="], + + "motion-dom": ["motion-dom@12.40.0", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg=="], + + "motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="], + "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], "ms": ["ms@2.1.3", "", {}, ""], diff --git a/cli/.gitignore b/cli/.gitignore new file mode 100644 index 0000000..8f85bc4 --- /dev/null +++ b/cli/.gitignore @@ -0,0 +1,64 @@ +# ─── Dependencies ──────────────────────────────────────────────────────────── +node_modules/ +.pnp +.pnp.js + +# ─── Build output ──────────────────────────────────────────────────────────── +dist/ +build/ +out/ +*.js.map + +# ─── Bun ───────────────────────────────────────────────────────────────────── +.bun/ +bun.lockb + +# ─── TypeScript ────────────────────────────────────────────────────────────── +*.tsbuildinfo +tsconfig.tsbuildinfo + +# ─── Test artifacts ────────────────────────────────────────────────────────── +coverage/ +.nyc_output/ +junit.xml +test-results/ + +# ─── Generated / scaffolded projects (CLI output) ──────────────────────────── +my_app/ + +# ─── Environment & secrets ─────────────────────────────────────────────────── +.env +.env.* +!.env.example +*.pem +*.key +*.secret + +# ─── Logs ──────────────────────────────────────────────────────────────────── +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +bun-debug.log* + +# ─── Package manager lockfiles (keep bun.lock, ignore others) ──────────────── +package-lock.json +yarn.lock +pnpm-lock.yaml + +# ─── Editor & IDE ──────────────────────────────────────────────────────────── +.vscode/ +.idea/ +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? +.DS_Store + +# ─── OS ────────────────────────────────────────────────────────────────────── +Thumbs.db +ehthumbs.db +Desktop.ini +$RECYCLE.BIN/ diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 0000000..1a16716 --- /dev/null +++ b/cli/README.md @@ -0,0 +1,47 @@ +
+ FlutterInit Logo + +
+ +

FlutterInit

+

The High-Performance Scaffolding Engine for Modern Flutter Apps

+ +

+ npm version + Build Status + License +

+ +
+ + + + + + + +
+ + Web Generator + + + + Read Docs + +
+
+ +--- + +**FlutterInit CLI** (`create-flutterinit`) is an interactive terminal tool that scaffolds production-ready Flutter applications. It bypasses the boilerplate, instantly generating a clean folder structure with your preferred architecture, state management, and backend pre-integrated. + +## ⚡ Quick Start (CLI) + +You don't need to install anything globally. Run the initializer directly in your terminal: + +```bash +# Using npm/npx (Standard Node) +npx create-flutterinit + +# Using Bun (Faster startup) +bunx create-flutterinit \ No newline at end of file diff --git a/cli/bin/index.ts b/cli/bin/index.ts new file mode 100644 index 0000000..da14d07 --- /dev/null +++ b/cli/bin/index.ts @@ -0,0 +1,3 @@ +#!/usr/bin/env node +import { main } from '../src/main' +main() diff --git a/cli/bun.lock b/cli/bun.lock new file mode 100644 index 0000000..ba7e983 --- /dev/null +++ b/cli/bun.lock @@ -0,0 +1,57 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "create-flutterinit", + "dependencies": { + "@clack/prompts": "^1.5.1", + "handlebars": "latest", + "picocolors": "latest", + }, + "devDependencies": { + "@types/bun": "latest", + "@types/handlebars": "latest", + "typescript": "latest", + }, + }, + }, + "packages": { + "@clack/core": ["@clack/core@1.4.1", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-FILJa1gGKEFTGZAJE9RpVhrjKz3c3h4ar60dSv6cGuDqufQ84YEIS3GAGvZiN+H6yaLbbvTFNejjCC4tXpZEuw=="], + + "@clack/prompts": ["@clack/prompts@1.5.1", "", { "dependencies": { "@clack/core": "1.4.1", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-zccHj2z2oCCO4yrDiRSlFOxWerGqRiysP7a5jPK6uoI9URKAquwY42Dd/iUP8JWHxEzdRe4TlbvZCo8z1/mhrw=="], + + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + + "@types/handlebars": ["@types/handlebars@4.1.0", "", { "dependencies": { "handlebars": "*" } }, "sha512-gq9YweFKNNB1uFK71eRqsd4niVkXrxHugqWFQkeLRJvGjnxsLr16bYtcsG4tOFwmYi0Bax+wCkbf1reUfdl4kA=="], + + "@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], + + "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], + + "fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="], + + "handlebars": ["handlebars@4.7.9", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": "bin/handlebars" }, "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ=="], + + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + + "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + + "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + + "uglify-js": ["uglify-js@3.19.3", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ=="], + + "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + + "wordwrap": ["wordwrap@1.0.0", "", {}, "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q=="], + } +} diff --git a/cli/bunfig.toml b/cli/bunfig.toml new file mode 100644 index 0000000..b64aea4 --- /dev/null +++ b/cli/bunfig.toml @@ -0,0 +1 @@ +[test] diff --git a/cli/package.json b/cli/package.json new file mode 100644 index 0000000..dca3e78 --- /dev/null +++ b/cli/package.json @@ -0,0 +1,45 @@ +{ + "name": "create-flutterinit", + "version": "0.1.11", + "description": "Scaffold production-ready Flutter projects from your terminal", + "bin": { + "create-flutterinit": "dist/index.js" + }, + "scripts": { + "dev": "bun run bin/index.ts", + "test": "bun test", + "build": "bun build ./bin/index.ts --outdir ./dist --target node", + "sync-templates": "bash ../scripts/sync-templates.sh" + }, + "files": [ + "dist/", + "templates/" + ], + "dependencies": { + "@clack/prompts": "^1.5.1", + "handlebars": "latest", + "picocolors": "latest" + }, + "devDependencies": { + "@types/bun": "latest", + "@types/handlebars": "latest", + "typescript": "latest" + }, + "keywords": [ + "flutter", + "scaffold", + "cli", + "boilerplate", + "clean-architecture", + "riverpod", + "bloc", + "flutterinit" + ], + "author": "Arjun Mahar ", + "license": "MIT", + "homepage": "https://flutterinit.com", + "repository": { + "type": "git", + "url": "https://github.com/Arjun544/flutter_init" + } +} \ No newline at end of file diff --git a/cli/src/config.ts b/cli/src/config.ts new file mode 100644 index 0000000..b8dc811 --- /dev/null +++ b/cli/src/config.ts @@ -0,0 +1,108 @@ +// ───────────────────────────────────────────────────────────────────────────── +// FlutterInit CLI — Configuration Types +// Single source of truth for all project configuration options. +// ───────────────────────────────────────────────────────────────────────────── + +export type Architecture = 'clean' | 'mvvm' | 'feature-first' | 'mvc' | 'layer-first' + +export type StateManager = 'riverpod' | 'bloc' | 'provider' | 'mobx' | 'getx' + +export type Backend = 'firebase' | 'supabase' | 'appwrite' | 'custom' | 'none' + +export type Navigation = 'gorouter' | 'autoroute' | 'none' + +export type ThemeMode = 'light' | 'dark' | 'both' + +export interface FlutterInitConfig { + projectName: string // validated: lowercase, underscores only e.g. my_app + orgName: string // reverse domain e.g. com.example + description: string // optional project description + architecture: Architecture + stateManager: StateManager + backend: Backend + navigation: Navigation + themeMode: ThemeMode + primaryColor: string // hex string e.g. #6750A4 + outputDir: string // resolved absolute path (cwd/projectName) + + // Icons selection + usesIconsaxPlus: boolean + usesFlutterRemix: boolean + usesHugeicons: boolean + + // Networking + usesDio: boolean + usesHttp: boolean + usesCachedNetworkImage: boolean + + // Persistence + usesHive: boolean + usesSharedPreferences: boolean + usesSecureStorage: boolean + + // Media & Assets + usesFlutterSvg: boolean + usesImagePicker: boolean + usesFilePicker: boolean + usesCamera: boolean + + // Essential Utilities + usesUrlLauncher: boolean + usesPathProvider: boolean + usesSharePlus: boolean + usesPermissionHandler: boolean + usesGeolocator: boolean + useLocalization: boolean + usesNotifications: boolean + + // Device & System + usesDeviceInfoPlus: boolean + usesAppVersionUpdate: boolean + usesFlutterNativeSplash: boolean + + // Advanced Features + usesFlutterHooks: boolean + usesSkeletonizer: boolean + usesScreenutil: boolean + usesDotenv: boolean + usesLogger: boolean + useMaterial3: boolean +} + +// ─── Architecture Labels ────────────────────────────────────────────────────── + +export const ARCHITECTURE_LABELS: Record = { + 'clean': 'Clean Architecture', + 'mvvm': 'MVVM', + 'feature-first': 'Feature-First', + 'mvc': 'MVC', + 'layer-first': 'Layer-First', +} + +export const STATE_LABELS: Record = { + 'riverpod': 'Riverpod', + 'bloc': 'Bloc / Cubit', + 'provider': 'Provider', + 'mobx': 'MobX', + 'getx': 'GetX', +} + +export const BACKEND_LABELS: Record = { + 'firebase': 'Firebase', + 'supabase': 'Supabase', + 'appwrite': 'Appwrite', + 'custom': 'Custom Backend', + 'none': 'None', +} + +export const NAVIGATION_LABELS: Record = { + 'gorouter': 'GoRouter', + 'autoroute': 'AutoRoute', + 'none': 'Navigator 2.0', +} + +export const THEME_LABELS: Record = { + 'light': 'Light only', + 'dark': 'Dark only', + 'both': 'Both (system)', +} diff --git a/cli/src/generator.ts b/cli/src/generator.ts new file mode 100644 index 0000000..6811142 --- /dev/null +++ b/cli/src/generator.ts @@ -0,0 +1,315 @@ +// ───────────────────────────────────────────────────────────────────────────── +// FlutterInit CLI — Project Generator +// Orchestrates: flutter create → template overlay → folder structure → +// pub get → dart analyze → outro +// ───────────────────────────────────────────────────────────────────────────── + +import { cancel, confirm, isCancel, note, outro, spinner } from '@clack/prompts' +import fs from 'fs' +import path from 'path' +import pc from 'picocolors' +import type { FlutterInitConfig } from './config' +import { configureNativeFiles } from './native' +import { buildTemplateContext, renderTemplate, TEMPLATE_ROOT } from './templates' +import { trackCliGeneration } from './utils/analytics' +import { exec } from './utils/exec' +import { createGitkeep, isDirNonEmpty, removeDir, writeFile } from './utils/fs' +import { brand, logError, logWarn } from './utils/logger' + +// ─── Architecture folder structures ─────────────────────────────────────────── + +const ARCH_FOLDERS: Record = { + clean: [ + 'lib/src/features/auth/data/datasources', + 'lib/src/features/auth/data/models', + 'lib/src/features/auth/data/repositories', + 'lib/src/features/auth/domain/entities', + 'lib/src/features/auth/domain/repositories', + 'lib/src/features/auth/domain/usecases', + 'lib/src/features/auth/presentation/pages', + 'lib/src/features/auth/presentation/widgets', + 'lib/src/features/home/data/datasources', + 'lib/src/features/home/data/models', + 'lib/src/features/home/data/repositories', + 'lib/src/features/home/domain/entities', + 'lib/src/features/home/domain/repositories', + 'lib/src/features/home/domain/usecases', + 'lib/src/features/home/presentation/pages', + 'lib/src/features/home/presentation/widgets', + 'lib/src/shared/widgets', + 'lib/src/shared/models', + 'lib/src/theme', + 'lib/src/routing', + 'lib/src/config', + 'lib/src/utils', + ], + mvvm: [ + 'lib/src/features/auth/model', + 'lib/src/features/auth/view', + 'lib/src/features/auth/viewmodel', + 'lib/src/features/home/model', + 'lib/src/features/home/view', + 'lib/src/features/home/viewmodel', + 'lib/src/shared/widgets', + 'lib/src/shared/models', + 'lib/src/theme', + 'lib/src/routing', + 'lib/src/config', + ], + 'feature-first': [ + 'lib/src/features/auth/screens', + 'lib/src/features/auth/widgets', + 'lib/src/features/auth/controllers', + 'lib/src/features/auth/models', + 'lib/src/features/auth/services', + 'lib/src/features/home/screens', + 'lib/src/features/home/widgets', + 'lib/src/features/home/controllers', + 'lib/src/features/home/models', + 'lib/src/shared/widgets', + 'lib/src/shared/constants', + 'lib/src/theme', + 'lib/src/routing', + ], + mvc: [ + 'lib/src/models', + 'lib/src/views/auth', + 'lib/src/views/home', + 'lib/src/controllers', + 'lib/src/services', + 'lib/src/widgets', + 'lib/src/theme', + 'lib/src/routing', + 'lib/src/utils', + ], + 'layer-first': [ + 'lib/src/data/datasources', + 'lib/src/data/models', + 'lib/src/data/repositories', + 'lib/src/domain/entities', + 'lib/src/domain/repositories', + 'lib/src/domain/usecases', + 'lib/src/presentation/pages', + 'lib/src/presentation/widgets', + 'lib/src/presentation/state', + 'lib/src/theme', + 'lib/src/routing', + 'lib/src/utils', + ], +} + +// ─── Helper: resolve conditional file names ─────────────────────────────────── +// +// Template files (and plain files) may be named with an optional condition +// prefix using the pattern: +// +// (flag1,flag2,...)@real_filename.ext[.hbs] +// +// ALL listed flags must be truthy in the template context for the file to be +// emitted. When emitted the output filename is the part after "@" (with the +// ".hbs" suffix already stripped by the caller). +// +// Examples: +// (usesSupabaseAuth)@auth_service.dart.hbs → auth_service.dart (only when usesSupabaseAuth) +// (isGetX)@app_bindings.dart.hbs → app_bindings.dart (only when isGetX) +// (isRiverpod,isProvider)@state.dart.hbs → state.dart (only when both are true) +// app.dart.hbs → app.dart (always) +// +// Returns null when the file should be skipped, otherwise the resolved output name. + +function resolveConditionalFilename( + rawName: string, // already has .hbs stripped if applicable + config: FlutterInitConfig, +): string | null { + const match = rawName.match(/^\(([^)]+)\)@(.+)$/) + if (!match) return rawName // no condition prefix — always emit as-is + + const flags = match[1]!.split(',').map((f) => f.trim()) + const realName = match[2]! + + // Build the context to access context.flags + const ctx = buildTemplateContext(config) + const flagsObj = (ctx.flags ?? {}) as Record + + const shouldInclude = flags.some((flag) => Boolean(flagsObj[flag])) + return shouldInclude ? realName : null +} + +// ─── Helper: walk a template directory and render/write all .hbs files ──────── + +function overlayTemplateDir( + templateDir: string, + outputDir: string, + config: FlutterInitConfig, +): void { + if (!fs.existsSync(templateDir)) return + + const entries = fs.readdirSync(templateDir, { withFileTypes: true }) + + for (const entry of entries) { + const srcPath = path.join(templateDir, entry.name) + + if (entry.isDirectory()) { + const destDir = path.join(outputDir, entry.name) + overlayTemplateDir(srcPath, destDir, config) + } else if (entry.name.endsWith('.hbs')) { + // Strip .hbs extension, then resolve any (condition)@name prefix + const nameWithoutHbs = entry.name.slice(0, -4) + const outputName = resolveConditionalFilename(nameWithoutHbs, config) + if (outputName === null) continue // condition not met — skip file + + const outputPath = path.join(outputDir, outputName) + + // Compute the relative template path for renderTemplate() + const relPath = path.relative(TEMPLATE_ROOT, srcPath) + const rendered = renderTemplate(relPath, config) + writeFile(outputPath, rendered) + } else { + // Non-HBS files (e.g. .gitignore, .yaml) — still resolve conditional names + const outputName = resolveConditionalFilename(entry.name, config) + if (outputName === null) continue // condition not met — skip file + + const outputPath = path.join(outputDir, outputName) + fs.mkdirSync(path.dirname(outputPath), { recursive: true }) + fs.copyFileSync(srcPath, outputPath) + } + } +} + +// ─── Main generator ─────────────────────────────────────────────────────────── + +export async function generateProject(config: FlutterInitConfig): Promise { + const { outputDir, projectName, orgName, architecture, backend, navigation } = config + + // ── Guard: check for existing non-empty directory ────────────────────────── + if (isDirNonEmpty(outputDir)) { + logWarn(`Directory already exists and is not empty: ${pc.dim(outputDir)}`) + const overwrite = await confirm({ + message: 'Overwrite the existing directory?', + initialValue: false, + }) + if (isCancel(overwrite) || !overwrite) { + cancel('Generation cancelled. cd to an empty directory and try again.') + process.exit(0) + } + removeDir(outputDir) + } + + const s = spinner() + + // ── Step 1: flutter create ───────────────────────────────────────────────── + s.start('Running flutter create...') + try { + exec( + `flutter create --org ${orgName} --project-name ${projectName} "${outputDir}"`, + ) + s.stop(`${pc.green('✓')} Flutter project scaffolded`) + } catch (err) { + s.stop(pc.red('✗ flutter create failed')) + logError(`flutter create failed: ${(err as Error).message}`) + removeDir(outputDir) + process.exit(1) + } + + // ── Step 2: Overlay base templates ──────────────────────────────────────── + s.start('Applying FlutterInit base templates...') + try { + const baseTemplateDir = path.join(TEMPLATE_ROOT, 'base') + overlayTemplateDir(baseTemplateDir, outputDir, config) + s.stop(`${pc.green('✓')} Base templates applied`) + } catch (err) { + s.stop(pc.red('✗ Template overlay failed')) + logError(`Template rendering failed: ${(err as Error).message}`) + removeDir(outputDir) + process.exit(1) + } + + // ── Step 3: Create architecture folder structure ─────────────────────────── + s.start(`Creating ${architecture} folder structure...`) + try { + const folders = ARCH_FOLDERS[architecture] ?? [] + for (const folder of folders) { + createGitkeep(path.join(outputDir, folder)) + } + s.stop(`${pc.green('✓')} ${ARCH_FOLDERS[architecture]?.length ?? 0} directories created`) + } catch (err) { + s.stop(pc.red('✗ Folder creation failed')) + logError(`Failed to create architecture folders: ${(err as Error).message}`) + removeDir(outputDir) + process.exit(1) + } + + // ── Step 4: Overlay architecture-specific templates ─────────────────────── + s.start('Applying architecture templates...') + try { + const archTemplateDir = path.join(TEMPLATE_ROOT, 'overlays', 'architecture', architecture) + overlayTemplateDir(archTemplateDir, outputDir, config) + s.stop(`${pc.green('✓')} Architecture templates applied`) + } catch (err) { + s.stop(pc.red('✗ Architecture template overlay failed')) + logError(`Architecture templates failed: ${(err as Error).message}`) + removeDir(outputDir) + process.exit(1) + } + + // ── Step 5: Overlay backend templates (if selected) ─────────────────────── + if (backend !== 'none') { + s.start(`Applying ${backend} backend templates...`) + try { + const backendTemplateDir = path.join(TEMPLATE_ROOT, 'overlays', 'backend', backend) + overlayTemplateDir(backendTemplateDir, outputDir, config) + s.stop(`${pc.green('✓')} ${backend} templates applied`) + } catch (err) { + s.stop(pc.yellow(`⚠ Backend templates had issues — ${(err as Error).message}`)) + // Non-fatal: warn but don't block + } + } + + // ── Step 6: Overlay navigation templates (if selected) ──────────────────── + if (navigation !== 'none') { + const navName = navigation === 'gorouter' ? 'go_router' : 'auto_route' + s.start(`Applying ${navName} navigation templates...`) + try { + const navTemplateDir = path.join(TEMPLATE_ROOT, 'overlays', 'navigation', navName) + if (fs.existsSync(navTemplateDir)) { + overlayTemplateDir(navTemplateDir, outputDir, config) + } + s.stop(`${pc.green('✓')} Navigation templates applied`) + } catch (err) { + s.stop(pc.yellow(`⚠ Navigation templates had issues — ${(err as Error).message}`)) + } + } + + // ── Step 7: Configure native permissions ────────────────────────────────── + const ns = spinner() + ns.start('Configuring native permissions...') + try { + await configureNativeFiles(config) + ns.stop(`${pc.green('✓')} Native permissions configured`) + } catch (err) { + ns.stop(pc.yellow('⚠ Native configuration skipped — check AndroidManifest.xml and Info.plist manually')) + logWarn(String(err)) + } + + // ── Step 8: Upload Generation Telemetry ──────────────────────────────────── + await trackCliGeneration(config) + + // ── Outro ───────────────────────────────────────────────────────────────── + note( + [ + `${pc.bold('cd')} "${outputDir}"`, + `${pc.bold('flutter pub get')}`, + `${pc.bold('flutter run')}`, + ``, + `${pc.dim('Or open in VS Code:')}`, + `${pc.bold('code')} "${outputDir}"`, + ``, + `${pc.dim('Docs & templates:')} ${pc.cyan('https://flutterinit.com')}`, + ].join('\n'), + 'Next steps', + ) + + outro( + `${brand(' FlutterInit ')} ${pc.dim('—')} ${pc.green('Your Flutter project is ready. Happy coding! 🚀')}`, + ) +} diff --git a/cli/src/main.ts b/cli/src/main.ts new file mode 100644 index 0000000..f835a05 --- /dev/null +++ b/cli/src/main.ts @@ -0,0 +1,16 @@ +// ───────────────────────────────────────────────────────────────────────────── +// FlutterInit CLI — Main Orchestrator +// Thin entry point: runs preflight → prompts → generation in sequence. +// ───────────────────────────────────────────────────────────────────────────── + +import { generateProject } from './generator' +import { runPreflight } from './preflight' +import { runPrompts } from './prompts' +import { printBanner } from './utils/logger' + +export async function main(): Promise { + printBanner() + await runPreflight() + const config = await runPrompts() + await generateProject(config) +} diff --git a/cli/src/native.ts b/cli/src/native.ts new file mode 100644 index 0000000..4370f97 --- /dev/null +++ b/cli/src/native.ts @@ -0,0 +1,288 @@ +// ───────────────────────────────────────────────────────────────────────────── +// FlutterInit CLI — Native File Configurator +// Post-processes AndroidManifest.xml and Info.plist to inject permissions and +// usage descriptions required by selected packages. +// +// Runs after flutter create (files exist) but before flutter pub get. +// All steps are non-fatal: a failure logs instructions and continues. +// ───────────────────────────────────────────────────────────────────────────── + +import { log } from '@clack/prompts' +import { readFileSync, writeFileSync } from 'fs' +import { join } from 'path' +import type { FlutterInitConfig } from './config' + +// ─── Entry point ────────────────────────────────────────────────────────────── + +export async function configureNativeFiles(config: FlutterInitConfig): Promise { + if (!hasAnyNativeFeature(config)) return + + await configureAndroid(config) + await configureIos(config) +} + +function hasAnyNativeFeature(config: FlutterInitConfig): boolean { + return ( + config.usesCamera || + config.usesImagePicker || + config.usesFilePicker || + config.usesGeolocator || + config.usesNotifications + ) +} + +// ─── Android ────────────────────────────────────────────────────────────────── + +async function configureAndroid(config: FlutterInitConfig): Promise { + const manifestPath = join( + config.outputDir, + 'android', + 'app', + 'src', + 'main', + 'AndroidManifest.xml', + ) + + try { + let manifest = readFileSync(manifestPath, 'utf-8') + + const permissions = buildAndroidPermissions(config) + if (permissions.length === 0) return + + // Deduplicate in case multiple features share the same permission + const unique = [...new Set(permissions)] + + // 4-space indent — standard AndroidManifest.xml convention + const permissionsBlock = unique + .map(p => ` `) + .join('\n') + + // Inject before . + manifest = manifest.replace( + /(\s*) { + const gradlePath = join( + config.outputDir, + 'android', + 'app', + 'build.gradle', + ) + + try { + // geolocator requires minSdk 19 (already met by Flutter default of 21). + // flutter_local_notifications requires minSdk 21 (already met). + // No SDK bumps needed for the current package set. + // This function is a no-op stub — easy to add bumps here when future + // packages require a higher minSdk. + // + // Example for a future bump: + // let gradle = readFileSync(gradlePath, 'utf-8') + // gradle = gradle.replace(/minSdk\s+\d+/, 'minSdk 23') + // writeFileSync(gradlePath, gradle, 'utf-8') + void gradlePath + } catch (err) { + log.warn(`build.gradle configuration failed: ${(err as Error).message}`) + } +} + +// ─── Android permission builder (exported for unit tests) ───────────────────── + +export function buildAndroidPermissions(config: FlutterInitConfig): string[] { + const permissions: string[] = [] + + if (config.usesCamera || config.usesImagePicker) { + permissions.push( + 'android.permission.CAMERA', + 'android.permission.READ_EXTERNAL_STORAGE', + 'android.permission.WRITE_EXTERNAL_STORAGE', + ) + } + + if (config.usesFilePicker) { + permissions.push( + 'android.permission.READ_EXTERNAL_STORAGE', + ) + } + + if (config.usesGeolocator) { + permissions.push( + 'android.permission.ACCESS_FINE_LOCATION', + 'android.permission.ACCESS_COARSE_LOCATION', + ) + } + + if (config.usesNotifications) { + permissions.push( + 'android.permission.RECEIVE_BOOT_COMPLETED', + 'android.permission.VIBRATE', + 'android.permission.WAKE_LOCK', + // POST_NOTIFICATIONS is required on Android 13+ (API 33+). + // Adding it unconditionally is safe — ignored on lower API levels. + 'android.permission.POST_NOTIFICATIONS', + ) + } + + return permissions +} + +// ─── iOS ────────────────────────────────────────────────────────────────────── + +async function configureIos(config: FlutterInitConfig): Promise { + const plistPath = join( + config.outputDir, + 'ios', + 'Runner', + 'Info.plist', + ) + + try { + let plist = readFileSync(plistPath, 'utf-8') + + const entries = buildIosPlistEntries(config) + if (entries.length === 0) return + + const entriesBlock = entries.join('\n') + + // Inject before the closing of the root dict. + // Info.plist always ends with \n. + // flutter create output is predictable here. + plist = plist.replace( + /(<\/dict>\s*<\/plist>)/, + `${entriesBlock}\n$1`, + ) + + writeFileSync(plistPath, plist, 'utf-8') + } catch (err) { + log.warn(`Info.plist configuration failed: ${(err as Error).message}`) + log.warn(`File path: ${plistPath}`) + logManualIosInstructions(config) + return + } + + await configureIosPodfile(config) +} + +async function configureIosPodfile(config: FlutterInitConfig): Promise { + const podfilePath = join(config.outputDir, 'ios', 'Podfile') + + try { + // geolocator requires iOS 10+ (already met by Flutter default of 12.0). + // image_picker requires iOS 11+ (already met). + // flutter_local_notifications requires iOS 10+ (already met). + // No platform bumps needed for the current package set. + // This function is a no-op stub — easy to add bumps here when future + // packages require a higher deployment target. + // + // Example for a future bump: + // let podfile = readFileSync(podfilePath, 'utf-8') + // podfile = podfile.replace(/platform :ios, '\d+\.\d+'/, "platform :ios, '13.0'") + // writeFileSync(podfilePath, podfile, 'utf-8') + void podfilePath + } catch (err) { + log.warn(`Podfile configuration failed: ${(err as Error).message}`) + } +} + +// ─── iOS plist entry builder (exported for unit tests) ──────────────────────── + +export function buildIosPlistEntries(config: FlutterInitConfig): string[] { + const entries: string[] = [] + + if (config.usesCamera || config.usesImagePicker) { + entries.push( + '\tNSCameraUsageDescription', + '\tThis app requires camera access', + '\tNSPhotoLibraryUsageDescription', + '\tThis app requires photo library access', + '\tNSPhotoLibraryAddUsageDescription', + '\tThis app requires photo library write access', + ) + } + + if (config.usesFilePicker) { + // file_picker on iOS does not require plist entries for the document + // picker. Entries are only needed when accessing the photo library. + entries.push( + '\tNSPhotoLibraryUsageDescription', + '\tThis app requires photo library access', + ) + } + + if (config.usesGeolocator) { + entries.push( + '\tNSLocationWhenInUseUsageDescription', + '\tThis app requires location access', + '\tNSLocationAlwaysAndWhenInUseUsageDescription', + '\tThis app requires location access', + ) + } + + if (config.usesNotifications) { + // flutter_local_notifications does not require Info.plist entries — + // UNUserNotificationCenter usage is handled at runtime. + // Block intentionally empty; kept for future additions. + } + + // Deduplicate by key — camera + filePicker both need NSPhotoLibraryUsageDescription. + return deduplicatePlistEntries(entries) +} + +// ─── Deduplication ──────────────────────────────────────────────────────────── + +function deduplicatePlistEntries(entries: string[]): string[] { + const seen = new Set() + const result: string[] = [] + + for (let i = 0; i < entries.length; i++) { + const line = entries[i]! + if (line.includes('')) { + if (seen.has(line)) { + i++ // skip the immediately following line too + continue + } + seen.add(line) + } + result.push(line) + } + + return result +} + +// ─── Manual instruction fallbacks ───────────────────────────────────────────── + +function logManualAndroidInstructions(config: FlutterInitConfig): void { + const permissions = buildAndroidPermissions(config) + if (permissions.length === 0) return + + log.warn('Add these manually to android/app/src/main/AndroidManifest.xml') + log.warn('Place them before the `) + } +} + +function logManualIosInstructions(config: FlutterInitConfig): void { + const entries = buildIosPlistEntries(config) + if (entries.length === 0) return + + log.warn('Add these manually to ios/Runner/Info.plist') + log.warn('Place them before the closing tag:\n') + for (const entry of entries) { + log.message(entry) + } +} diff --git a/cli/src/preflight.ts b/cli/src/preflight.ts new file mode 100644 index 0000000..5c92353 --- /dev/null +++ b/cli/src/preflight.ts @@ -0,0 +1,75 @@ +// ───────────────────────────────────────────────────────────────────────────── +// FlutterInit CLI — Preflight Checks +// Runs before any prompt. Verifies Flutter is installed and configured. +// ───────────────────────────────────────────────────────────────────────────── + +import { cancel, confirm, isCancel, log, spinner } from '@clack/prompts' +import pc from 'picocolors' +import { exec, execVisible } from './utils/exec' +import { brand, logError, logInfo, logWarn } from './utils/logger' + +/** + * Run all preflight checks in order. + * Exits the process on any hard failure. + */ +export async function runPreflight(): Promise { + // ── 1. Check Flutter is on PATH ──────────────────────────────────────────── + const flutterSpinner = spinner() + flutterSpinner.start('Checking Flutter installation...') + + try { + const version = exec('flutter --version') + // Extract the version line for a clean display + const versionLine = version.split('\n')[0]?.trim() ?? 'Flutter (version unknown)' + flutterSpinner.stop(`${pc.green('✓')} ${versionLine}`) + } catch { + flutterSpinner.stop(pc.red('✗ Flutter not found on PATH')) + logError('Flutter SDK is required but was not found.') + log.message( + ` Install Flutter: ${pc.cyan('https://flutter.dev/docs/get-started/install')}`, + ) + process.exit(1) + } + + // ── 2. Check Bun version ────────────────────────────────────────────────── + try { + const bunVersion = exec('bun --version').trim() + const [major] = bunVersion.split('.').map(Number) + if (major < 1) { + logWarn(`Bun v${bunVersion} detected. Bun 1.0.0+ is recommended.`) + } else { + logInfo(`Bun v${bunVersion}`) + } + } catch { + logWarn('Could not detect Bun version. Continuing anyway.') + } + + // ── 3. Run flutter doctor ───────────────────────────────────────────────── + console.log() + log.message( + `${brand('─────────────────────────────────────────────────────────────')}\n` + + ` Running ${pc.bold('flutter doctor')} to check your environment...\n` + + `${brand('─────────────────────────────────────────────────────────────')}` + ) + console.log() + + try { + execVisible('flutter doctor') + } catch { + // flutter doctor itself can exit non-zero with warnings — we still continue + } + + console.log() + + // ── 4. Ask user to confirm they want to continue ────────────────────────── + const shouldContinue = await confirm({ + message: 'Flutter doctor ran above. Do you want to continue?', + initialValue: true, + }) + + if (isCancel(shouldContinue) || !shouldContinue) { + cancel('Fix any Flutter issues first, then run create-flutterinit again.') + log.message(` ${pc.cyan('https://flutter.dev/docs/get-started/install')}`) + process.exit(0) + } +} diff --git a/cli/src/prompts.ts b/cli/src/prompts.ts new file mode 100644 index 0000000..7830e9d --- /dev/null +++ b/cli/src/prompts.ts @@ -0,0 +1,597 @@ +// ───────────────────────────────────────────────────────────────────────────── +// FlutterInit CLI — Interactive Prompts +// Full Clack.js prompt flow. Returns a completed FlutterInitConfig. +// Every prompt is followed by an isCancel() check — no exceptions. +// ───────────────────────────────────────────────────────────────────────────── + +import { + cancel, + confirm, + groupMultiselect, + isCancel, + note, + select, + text +} from '@clack/prompts' +import path from 'path' +import pc from 'picocolors' +import { + ARCHITECTURE_LABELS, + BACKEND_LABELS, + NAVIGATION_LABELS, + STATE_LABELS, + THEME_LABELS, + type Architecture, + type Backend, + type FlutterInitConfig, + type Navigation, + type StateManager, + type ThemeMode, +} from './config' +import { printStep } from './utils/logger' + +// ─── Cancel helper ──────────────────────────────────────────────────────────── + +function checkCancel(value: unknown): asserts value is NonNullable { + if (isCancel(value)) { + cancel('Cancelled. Run create-flutterinit again whenever you\'re ready.') + process.exit(0) + } +} + +// ─── Main prompt orchestrator ───────────────────────────────────────────────── + +export async function runPrompts(): Promise { + + // ── Section: Project Identity ────────────────────────────────────────────── + printStep( + 'Project Identity', + 'Basic information about your Flutter application.', + ) + + const projectName = await text({ + message: `Project name ${pc.dim('(lowercase letters, numbers, and underscores)')}`, + placeholder: 'my_app', + validate(value) { + if (!value || value.trim().length === 0) return 'Project name is required.' + if (!/^[a-z][a-z0-9_]*$/.test(value.trim())) + return 'Must be lowercase with underscores only — e.g. my_app' + return undefined + }, + }) + checkCancel(projectName) + const name = (projectName as string).trim() + + const orgName = await text({ + message: `Organisation name ${pc.dim('(reverse domain format — e.g. com.yourcompany)')}`, + placeholder: 'com.example', + defaultValue: 'com.example', + validate(value) { + const v = value || 'com.example' + if (!/^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)+$/.test(v)) + return 'Use reverse domain format — e.g. com.example or com.acme.mobile' + return undefined + }, + }) + checkCancel(orgName) + + const description = await text({ + message: 'Project description', + placeholder: 'A new Flutter project', + defaultValue: 'A new Flutter project', + }) + checkCancel(description) + + // ── Section: Architecture ────────────────────────────────────────────────── + printStep( + 'Architecture', + 'Choose how your project folders and layers are organized.', + ) + + const architecture = await select({ + message: 'Architecture pattern', + options: [ + { + value: 'clean', + label: 'Clean Architecture', + hint: 'Data → Domain → Presentation layers. Best for large teams.', + }, + { + value: 'mvvm', + label: 'MVVM', + hint: 'Model-View-ViewModel. Great with Riverpod or Provider.', + }, + { + value: 'feature-first', + label: 'Feature-First', + hint: 'Organized by feature, not layer. Scales well for medium apps.', + }, + { + value: 'mvc', + label: 'MVC', + hint: 'Model-View-Controller. Familiar pattern for most developers.', + }, + { + value: 'layer-first', + label: 'Layer-First', + hint: 'Global shared layers. Simple, good for smaller apps.', + }, + ], + }) + checkCancel(architecture) + + // ── Section: State Management ────────────────────────────────────────────── + printStep( + 'State Management', + 'The state management solution wired throughout your app.', + ) + + const stateManager = await select({ + message: 'State manager', + options: [ + { + value: 'riverpod', + label: 'Riverpod', + hint: 'AsyncNotifier + Riverpod Generator. Compile-safe & testable.', + }, + { + value: 'bloc', + label: 'Bloc / Cubit', + hint: 'Event-driven. Strict separation, excellent for large teams.', + }, + { + value: 'provider', + label: 'Provider', + hint: 'Simple InheritedWidget wrapper. Great for smaller apps.', + }, + { + value: 'mobx', + label: 'MobX', + hint: 'Reactive observables with code generation.', + }, + { + value: 'getx', + label: 'GetX', + hint: 'All-in-one: state, routing, DI. Opinionated but fast.', + }, + ], + }) + checkCancel(stateManager) + + // ── Section: Backend ─────────────────────────────────────────────────────── + printStep( + 'Backend', + 'Backend-as-a-service to wire into your data layer.', + ) + + const backend = await select({ + message: 'Backend service', + options: [ + { + value: 'firebase', + label: 'Firebase', + hint: 'Auth, Firestore, Storage, FCM. Google ecosystem.', + }, + { + value: 'supabase', + label: 'Supabase', + hint: 'Open-source Firebase alternative. Postgres + realtime.', + }, + { + value: 'appwrite', + label: 'Appwrite', + hint: 'Self-hostable BaaS. Full ownership of your data.', + }, + { + value: 'custom', + label: 'Custom Backend', + hint: 'Connect to your own REST API/service via AppConfig.', + }, + { + value: 'none', + label: 'None', + hint: 'No backend wired in. Add your own later.', + }, + ], + }) + checkCancel(backend) + + // ── Section: Navigation ──────────────────────────────────────────────────── + printStep( + 'Navigation', + 'Routing solution for navigating between screens.', + ) + + const navigation = await select({ + message: 'Navigation package', + options: [ + { + value: 'gorouter', + label: 'GoRouter', + hint: 'Official Flutter routing. URL-based, deep-link ready.', + }, + { + value: 'autoroute', + label: 'AutoRoute', + hint: 'Code-generated typed routes. Zero string-based navigation.', + }, + { + value: 'none', + label: 'Navigator 2.0', + hint: 'Vanilla Flutter navigation. No extra dependency.', + }, + ], + }) + checkCancel(navigation) + + // ── Section: Theme ──────────────────────────────────────────────────────── + printStep( + 'Theme & Appearance', + 'Material 3 color scheme and theme mode for your app.', + ) + + const themeMode = await select({ + message: 'Theme mode', + options: [ + { + value: 'both', + label: 'Both (system default)', + hint: 'App respects the device light/dark preference.', + }, + { + value: 'light', + label: 'Light only', + hint: 'Always renders in light mode.', + }, + { + value: 'dark', + label: 'Dark only', + hint: 'Always renders in dark mode.', + }, + ], + }) + checkCancel(themeMode) + + const primaryColor = await text({ + message: `Primary color ${pc.dim('(hex seed color for color scheme)')}`, + placeholder: '#027DFD', + defaultValue: '#027DFD', + validate(value) { + const v = value || '#027DFD' + if (!/^#[0-9A-Fa-f]{6}$/.test(v)) + return 'Enter a valid 6-digit hex color — e.g. #6750A4' + return undefined + }, + }) + checkCancel(primaryColor) + + // ── Section: Optional Utilities (Consolidated using groupMultiselect) ────── + printStep( + 'Optional Utilities & Features', + 'Select additional packages and features to pre-configure in your codebase.', + ) + + const selectedMiscResult = await groupMultiselect({ + message: 'Select packages to include (Press Space to select & Enter to confirm)', + options: { + 'Icon Packs': [ + { + value: 'usesIconsaxPlus', + label: 'Iconsax Plus', + hint: 'Modern icon set with 6 distinct styles (linear, bold, etc.)', + }, + { + value: 'usesFlutterRemix', + label: 'Flutter Remix', + hint: 'Remix Icon library package wrapper', + }, + { + value: 'usesHugeicons', + label: 'Hugeicons', + hint: 'Free stroke outline icons pack', + }, + ], + 'Networking & Storage': [ + { + value: 'usesDio', + label: 'Dio', + hint: 'Powerful HTTP client with interceptors, form data & caching [Recommended]', + }, + { + value: 'usesHttp', + label: 'HTTP Client', + hint: 'Official lightweight Dart http package', + }, + { + value: 'usesCachedNetworkImage', + label: 'Cached Network Image', + hint: 'Download, render and cache network images automatically [Popular]', + }, + { + value: 'usesHive', + label: 'Hive Database', + hint: 'Lightweight & blazing fast key-value NoSQL database', + }, + { + value: 'usesSharedPreferences', + label: 'Shared Preferences', + hint: 'Platform-persistent key-value pairs storage [Essential]', + }, + { + value: 'usesSecureStorage', + label: 'Secure Storage', + hint: 'Store credentials/sensitive data securely (Keychain/Keystore)', + }, + ], + 'Media & Assets': [ + { + value: 'usesFlutterSvg', + label: 'Flutter SVG', + hint: 'Vector SVG rendering support [Popular]', + }, + { + value: 'usesImagePicker', + label: 'Image Picker', + hint: 'Pick images/videos from gallery or shoot new ones with camera', + }, + { + value: 'usesCamera', + label: 'Camera', + hint: 'camera package — full camera control + recording', + }, + { + value: 'usesFilePicker', + label: 'File Picker', + hint: 'Native file explorer to upload files/documents', + }, + { + value: 'usesFlutterNativeSplash', + label: 'Flutter Native Splash', + hint: 'Automatic native splash screens config', + }, + ], + 'Essential Utilities': [ + { + value: 'usesUrlLauncher', + label: 'URL Launcher', + hint: 'Trigger browser URLs, map locations, SMS, and telephone calls [Essential]', + }, + { + value: 'usesPathProvider', + label: 'Path Provider', + hint: 'Locate commonly used app folders on device filesystem [Essential]', + }, + { + value: 'usesSharePlus', + label: 'Share Plus', + hint: 'Trigger native system share panels for links, images & text', + }, + { + value: 'usesPermissionHandler', + label: 'Permission Handler', + hint: 'Query and request system hardware permissions dynamically [Essential]', + }, + { + value: 'usesDeviceInfoPlus', + label: 'Device Info', + hint: 'Access deep hardware model/OS version properties', + }, + { + value: 'usesGeolocator', + label: 'Geolocator', + hint: 'Acquire and track device GPS location updates', + }, + { + value: 'usesNotifications', + label: 'Local Notifications', + hint: 'flutter_local_notifications — schedule and display local alerts', + }, + { + value: 'usesAppVersionUpdate', + label: 'App Version Update', + hint: 'Verify and alert users when an app update is available', + }, + ], + 'Advanced Features': [ + { + value: 'usesFlutterHooks', + label: 'Flutter Hooks', + hint: 'React-style code structure for widget lifecycle states [Popular]', + }, + { + value: 'usesSkeletonizer', + label: 'Skeletonizer', + hint: 'Transform simple widgets into custom shimmering loader states [UI]', + }, + { + value: 'usesScreenutil', + label: 'ScreenUtil', + hint: 'Sizing & font scaling adapter for responsive layouts [Popular]', + }, + { + value: 'usesDotenv', + label: 'Environment Config (.env)', + hint: 'Pre-configure flutter_dotenv for configuration files support', + }, + { + value: 'usesLogger', + label: 'Console Logger', + hint: 'Logger package for clean output filters', + }, + { + value: 'useLocalization', + label: 'Localization (intl)', + hint: 'Integrate native multi-language supported structures', + }, + { + value: 'useMaterial3', + label: 'Material 3 support', + hint: 'Configure global ThemeData to support M3 guidelines', + }, + ], + }, + required: false, + initialValues: [ + 'usesIconsaxPlus', + 'usesDio', + 'usesSharedPreferences', + 'usesSecureStorage', + 'usesCachedNetworkImage', + 'usesFlutterSvg', + 'usesFlutterNativeSplash', + 'usesUrlLauncher', + 'usesPathProvider', + 'usesPermissionHandler', + 'usesDeviceInfoPlus', + 'usesAppVersionUpdate', + 'usesScreenutil', + 'usesDotenv', + 'usesLogger', + 'useLocalization', + 'useMaterial3', + ], + }) + checkCancel(selectedMiscResult) + const selectedMisc = selectedMiscResult as string[] + + // ── Derive native feature flags ──────────────────────────────────────────── + const usesCamera = selectedMisc.includes('usesCamera') + const usesImagePicker = selectedMisc.includes('usesImagePicker') + const usesFilePicker = selectedMisc.includes('usesFilePicker') + const usesGeolocator = selectedMisc.includes('usesGeolocator') + const usesNotifications = selectedMisc.includes('usesNotifications') + + // Auto-enable permission_handler if any native feature requiring runtime permissions is selected + const needsPermissionHandler = + usesCamera || usesImagePicker || usesFilePicker || usesGeolocator || usesNotifications + const usesPermissionHandler = + selectedMisc.includes('usesPermissionHandler') || needsPermissionHandler + + const allSelected = [ + ...(selectedMisc.includes('usesIconsaxPlus') ? ['Iconsax Plus'] : []), + ...(selectedMisc.includes('usesFlutterRemix') ? ['Flutter Remix'] : []), + ...(selectedMisc.includes('usesHugeicons') ? ['Hugeicons'] : []), + ...(selectedMisc.includes('usesDio') ? ['Dio'] : []), + ...(selectedMisc.includes('usesHttp') ? ['HTTP'] : []), + ...(selectedMisc.includes('usesCachedNetworkImage') ? ['Cached Image'] : []), + ...(selectedMisc.includes('usesHive') ? ['Hive'] : []), + ...(selectedMisc.includes('usesSharedPreferences') ? ['SharedPreferences'] : []), + ...(selectedMisc.includes('usesSecureStorage') ? ['SecureStorage'] : []), + ...(selectedMisc.includes('usesFlutterSvg') ? ['Flutter SVG'] : []), + ...(usesImagePicker ? ['Image Picker'] : []), + ...(usesCamera ? ['Camera'] : []), + ...(usesFilePicker ? ['File Picker'] : []), + ...(selectedMisc.includes('usesFlutterNativeSplash') ? ['Native Splash'] : []), + ...(selectedMisc.includes('usesUrlLauncher') ? ['URL Launcher'] : []), + ...(selectedMisc.includes('usesPathProvider') ? ['Path Provider'] : []), + ...(selectedMisc.includes('usesSharePlus') ? ['Share Plus'] : []), + ...(usesPermissionHandler ? ['Permission Handler'] : []), + ...(selectedMisc.includes('usesDeviceInfoPlus') ? ['Device Info'] : []), + ...(usesGeolocator ? ['Geolocator'] : []), + ...(usesNotifications ? ['Local Notifications'] : []), + ...(selectedMisc.includes('usesAppVersionUpdate') ? ['App Version Update'] : []), + ...(selectedMisc.includes('usesFlutterHooks') ? ['Flutter Hooks'] : []), + ...(selectedMisc.includes('usesSkeletonizer') ? ['Skeletonizer'] : []), + ...(selectedMisc.includes('usesScreenutil') ? ['ScreenUtil'] : []), + ...(selectedMisc.includes('usesDotenv') ? ['Dotenv'] : []), + ...(selectedMisc.includes('usesLogger') ? ['Logger'] : []), + ...(selectedMisc.includes('useLocalization') ? ['Localization'] : []), + ...(selectedMisc.includes('useMaterial3') ? ['Material 3'] : []), + ] + + // ── Resolve output directory ─────────────────────────────────────────────── + const outputDir = path.resolve(process.cwd(), name) + + // ── Summary ─────────────────────────────────────────────────────────────── + const nativeFeatures = [ + usesCamera && 'Camera', + usesImagePicker && 'Image Picker', + usesFilePicker && 'File Picker', + usesGeolocator && 'Location', + usesNotifications && 'Notifications', + ].filter(Boolean).join(', ') + + note( + [ + `${pc.bold('Project')} ${pc.cyan(name)}`, + `${pc.bold('Org')} ${orgName as string || 'com.example'}`, + `${pc.bold('Description')} ${description as string || 'A new Flutter project'}`, + ``, + `${pc.bold('Architecture')} ${ARCHITECTURE_LABELS[architecture as Architecture]}`, + `${pc.bold('State')} ${STATE_LABELS[stateManager as StateManager]}`, + `${pc.bold('Backend')} ${BACKEND_LABELS[backend as Backend]}`, + `${pc.bold('Navigation')} ${NAVIGATION_LABELS[navigation as Navigation]}`, + ``, + `${pc.bold('Theme')} ${THEME_LABELS[themeMode as ThemeMode]}`, + `${pc.bold('Color')} ${primaryColor as string || '#027DFD'}`, + `${pc.bold('Utilities')} ${allSelected.length > 0 ? allSelected.join(', ') : 'none'}`, + `${pc.bold('Native')} ${nativeFeatures || 'None'}`, + ``, + `${pc.bold('Output')} ${pc.dim(outputDir)}`, + ].join('\n'), + 'Your FlutterInit Configuration', + ) + + const confirmed = await confirm({ + message: 'Generate this project?', + initialValue: true, + }) + + if (isCancel(confirmed) || !confirmed) { + cancel('Generation cancelled. Run create-flutterinit again to start over.') + process.exit(0) + } + + // ── Build and return config ──────────────────────────────────────────────── + return { + projectName: name, + orgName: (orgName as string) || 'com.example', + description: (description as string) || 'A new Flutter project', + architecture: architecture as Architecture, + stateManager: stateManager as StateManager, + backend: backend as Backend, + navigation: navigation as Navigation, + themeMode: themeMode as ThemeMode, + primaryColor: (primaryColor as string) || '#027DFD', + outputDir, + + // Icons + usesIconsaxPlus: selectedMisc.includes('usesIconsaxPlus'), + usesFlutterRemix: selectedMisc.includes('usesFlutterRemix'), + usesHugeicons: selectedMisc.includes('usesHugeicons'), + + // Networking & Storage + usesDio: selectedMisc.includes('usesDio'), + usesHttp: selectedMisc.includes('usesHttp'), + usesCachedNetworkImage: selectedMisc.includes('usesCachedNetworkImage'), + usesHive: selectedMisc.includes('usesHive'), + usesSharedPreferences: selectedMisc.includes('usesSharedPreferences'), + usesSecureStorage: selectedMisc.includes('usesSecureStorage'), + + // Media & Assets + usesFlutterSvg: selectedMisc.includes('usesFlutterSvg'), + usesImagePicker, + usesCamera, + usesFilePicker, + usesFlutterNativeSplash: selectedMisc.includes('usesFlutterNativeSplash'), + + // Essential Utilities + usesUrlLauncher: selectedMisc.includes('usesUrlLauncher'), + usesPathProvider: selectedMisc.includes('usesPathProvider'), + usesSharePlus: selectedMisc.includes('usesSharePlus'), + usesPermissionHandler, // auto-derived from native features + usesDeviceInfoPlus: selectedMisc.includes('usesDeviceInfoPlus'), + usesGeolocator, + usesNotifications, + usesAppVersionUpdate: selectedMisc.includes('usesAppVersionUpdate'), + + // Advanced Features + usesFlutterHooks: selectedMisc.includes('usesFlutterHooks'), + usesSkeletonizer: selectedMisc.includes('usesSkeletonizer'), + usesScreenutil: selectedMisc.includes('usesScreenutil'), + usesDotenv: selectedMisc.includes('usesDotenv'), + usesLogger: selectedMisc.includes('usesLogger'), + useLocalization: selectedMisc.includes('useLocalization'), + useMaterial3: selectedMisc.includes('useMaterial3'), + } +} diff --git a/cli/src/templates.ts b/cli/src/templates.ts new file mode 100644 index 0000000..f817908 --- /dev/null +++ b/cli/src/templates.ts @@ -0,0 +1,297 @@ +// ───────────────────────────────────────────────────────────────────────────── +// FlutterInit CLI — Template Loader & Renderer +// Loads Handlebars .hbs templates from cli/templates/, registers helpers, +// computes derived booleans, and renders against FlutterInitConfig. +// ───────────────────────────────────────────────────────────────────────────── + +import fs from 'fs' +import Handlebars from 'handlebars' +import path from 'path' +import { fileURLToPath } from 'url' +import type { FlutterInitConfig } from './config' + +// ── Resolve template root ────────────────────────────────────────────────── +// Works both in monorepo (bun run dev) and as published npm package +// (templates/ is bundled alongside the package via "files" in package.json) + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +// When compiled: dist/ sits next to templates/ +// When running via bun run dev: src/ → cli root → templates/ +const TEMPLATE_ROOT = (() => { + // Try relative to current file first (works from src/ in monorepo, pointing to templates/flutter) + const fromSrc = path.resolve(__dirname, '../../templates/flutter') + if (fs.existsSync(fromSrc)) return fromSrc + // Then try from dist/ or when templates are synced to cli/templates + const fromDist = path.resolve(__dirname, '../templates') + if (fs.existsSync(fromDist)) return fromDist + // Fallback to cwd-relative (running from cli/ root with bun) + return path.resolve(process.cwd(), 'templates') +})() + +// ── Register Handlebars partials ─────────────────────────────────────────── + +const PARTIALS_ROOT = path.join(TEMPLATE_ROOT, 'partials') + +function registerPartialsSync(dir: string) { + if (!fs.existsSync(dir)) return + + const entries = fs.readdirSync(dir, { withFileTypes: true }) + for (const entry of entries) { + const fullPath = path.join(dir, entry.name) + if (entry.isDirectory()) { + registerPartialsSync(fullPath) + } else if (entry.isFile() && entry.name.endsWith('.hbs')) { + const contents = fs.readFileSync(fullPath, 'utf8') + const rel = path.relative(PARTIALS_ROOT, fullPath) + const name = rel.replace(/\\/g, '/').replace(/\.hbs$/, '') + Handlebars.registerPartial(name, contents) + } + } +} + +registerPartialsSync(PARTIALS_ROOT) + +// ── Helpers ──────────────────────────────────────────────────────────────── + +function kebabCase(value: string) { + return value + .replace(/([a-z])([A-Z])/g, '$1-$2') + .replace(/[^a-zA-Z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .toLowerCase() +} + +function snakeCase(value: string) { + return value + .replace(/([a-z])([A-Z])/g, '$1_$2') + .replace(/[^a-zA-Z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') + .toLowerCase() +} + +function pascalCase(value: string) { + return value + .replace(/(^\w|[-_\s]+\w)/g, (match) => + match.replace(/[-_\s]/g, '').toUpperCase() + ) + .replace(/[^a-zA-Z0-9]/g, '') +} + +function indentLines(text: string, spaces: number) { + const pad = ' '.repeat(spaces) + return text + .split('\n') + .map((line) => (line.length ? pad + line : line)) + .join('\n') +} + +// ── Register Handlebars helpers ──────────────────────────────────────────── + +Handlebars.registerHelper('eq', (a: unknown, b: unknown) => a === b) + +Handlebars.registerHelper('includes', (arr: unknown[], val: unknown) => + Array.isArray(arr) && arr.includes(val), +) + +Handlebars.registerHelper('capitalize', (str: string) => + str ? str.charAt(0).toUpperCase() + str.slice(1) : '', +) + +Handlebars.registerHelper('and', function (...args: unknown[]) { + return args.slice(0, -1).every(Boolean) +}) + +Handlebars.registerHelper('or', function (...args: unknown[]) { + return args.slice(0, -1).some(Boolean) +}) + +Handlebars.registerHelper('not', (value: unknown) => !value) +Handlebars.registerHelper('kebabCase', kebabCase) +Handlebars.registerHelper('snakeCase', snakeCase) +Handlebars.registerHelper('pascalCase', pascalCase) +Handlebars.registerHelper('json', (value) => JSON.stringify(value, null, 2)) +Handlebars.registerHelper('indent', (text: string, spaces = 2) => + indentLines(text, Number(spaces)) +) +Handlebars.registerHelper('res', (value: unknown, unit: string, usesScreenutil: boolean) => { + if (usesScreenutil) return `${value}.${unit}` + return String(value) +}) +Handlebars.registerHelper('when', function (this: unknown, condition, options) { + return condition ? options.fn(this) : options.inverse(this) +}) + +// ── Derived boolean context ──────────────────────────────────────────────── + +export interface TemplateContext extends Omit { + appName: string + backend: any + isRiverpod: boolean + isBloc: boolean + isProvider: boolean + isMobX: boolean + isGetX: boolean + isCleanArch: boolean + isMvvm: boolean + isFeatureFirst: boolean + isMvc: boolean + isLayerFirst: boolean + hasFirebase: boolean + hasSupabase: boolean + hasAppwrite: boolean + hasBackend: boolean + hasGoRouter: boolean + hasAutoRoute: boolean + hasNavigation: boolean + hasDarkMode: boolean + hasLightMode: boolean + hasBothModes: boolean + flags: any +} + +export function buildTemplateContext(config: FlutterInitConfig): TemplateContext { + const routerPackage = + config.navigation === 'gorouter' + ? 'go_router' + : config.navigation === 'autoroute' + ? 'auto_route' + : undefined + + const appSnake = config.projectName.trim().replace(/\s+/g, '_').toLowerCase() + const appSlug = config.projectName.trim().replace(/\s+/g, '-').toLowerCase() + + const flags = { + appSlug, + appSnake, + routerPackage, + usesRouting: Boolean(routerPackage), + isRiverpod: config.stateManager === 'riverpod', + isProvider: config.stateManager === 'provider', + isBloc: config.stateManager === 'bloc', + isGetX: config.stateManager === 'getx', + isMobX: config.stateManager === 'mobx', + isNoneState: false, + usesFirebase: config.backend === 'firebase', + usesSupabase: config.backend === 'supabase', + usesAppwrite: config.backend === 'appwrite', + usesCustomBackend: config.backend === 'custom', + usesDio: config.usesDio, + usesHttp: config.usesHttp, + usesHive: config.usesHive, + usesSharedPreferences: config.usesSharedPreferences, + usesSecureStorage: config.usesSecureStorage, + usesCachedNetworkImage: config.usesCachedNetworkImage, + usesFlutterSvg: config.usesFlutterSvg, + usesSkeletonizer: config.usesSkeletonizer, + usesScreenutil: config.usesScreenutil, + usesFlutterNativeSplash: config.usesFlutterNativeSplash, + usesLogger: config.usesLogger, + usesDotenv: config.usesDotenv, + usesIconsaxPlus: config.usesIconsaxPlus, + usesFlutterRemix: config.usesFlutterRemix, + usesHugeicons: config.usesHugeicons, + supportsLocalization: config.useLocalization, + supportedLocales: config.useLocalization ? ['en', 'es'] : ['en'], + fallbackLocale: 'en', + hasFlavors: true, + hasDarkMode: config.themeMode !== 'light', + isCupertino: false, + isCustomTheme: true, + usesFlutterHooks: config.usesFlutterHooks, + usesImagePicker: config.usesImagePicker, + usesCamera: config.usesCamera, + usesFilePicker: config.usesFilePicker, + usesPathProvider: config.usesPathProvider, + usesSharePlus: config.usesSharePlus, + usesPermissionHandler: config.usesPermissionHandler, + usesUrlLauncher: config.usesUrlLauncher, + usesDeviceInfoPlus: config.usesDeviceInfoPlus, + usesAppVersionUpdate: config.usesAppVersionUpdate, + usesGeolocator: config.usesGeolocator, + usesNotifications: config.usesNotifications, + usesFirebaseAuth: config.backend === 'firebase', + usesFirebaseFirestore: config.backend === 'firebase', + usesFirebaseStorage: config.backend === 'firebase', + usesSupabaseAuth: config.backend === 'supabase', + usesSupabaseDb: config.backend === 'supabase', + usesAppwriteAuth: config.backend === 'appwrite', + usesAppwriteDb: config.backend === 'appwrite', + hasCustomFonts: false, + primaryFontFamily: '', + fontFamilies: [] as any[], + } + + const backend = { + provider: config.backend, + options: { + authEmail: config.backend === 'firebase', + firestore: config.backend === 'firebase', + realtimeDb: false, + storage: config.backend === 'firebase', + analytics: config.backend === 'firebase', + crashlytics: config.backend === 'firebase', + auth: config.backend !== 'none', + database: config.backend !== 'none', + } + } + + return { + ...config, + appName: config.projectName, + backend, + flags, + isRiverpod: config.stateManager === 'riverpod', + isBloc: config.stateManager === 'bloc', + isProvider: config.stateManager === 'provider', + isMobX: config.stateManager === 'mobx', + isGetX: config.stateManager === 'getx', + isCleanArch: config.architecture === 'clean', + isMvvm: config.architecture === 'mvvm', + isFeatureFirst: config.architecture === 'feature-first', + isMvc: config.architecture === 'mvc', + isLayerFirst: config.architecture === 'layer-first', + hasFirebase: config.backend === 'firebase', + hasSupabase: config.backend === 'supabase', + hasAppwrite: config.backend === 'appwrite', + hasBackend: config.backend !== 'none', + hasGoRouter: config.navigation === 'gorouter', + hasAutoRoute: config.navigation === 'autoroute', + hasNavigation: config.navigation !== 'none', + hasDarkMode: config.themeMode !== 'light', + hasLightMode: config.themeMode !== 'dark', + hasBothModes: config.themeMode === 'both', + } +} + +// ── Template renderer ────────────────────────────────────────────────────── + +/** + * Load and render a Handlebars template by name. + * + * @param templatePath Relative path within templates/ — e.g. 'base/pubspec.yaml.hbs' + * @param config FlutterInitConfig to render against + * @returns Rendered string output + */ +export function renderTemplate(templatePath: string, config: FlutterInitConfig): string { + const fullPath = path.join(TEMPLATE_ROOT, templatePath) + + if (!fs.existsSync(fullPath)) { + throw new Error(`Template not found: ${fullPath}`) + } + + const source = fs.readFileSync(fullPath, 'utf-8') + const template = Handlebars.compile(source, { noEscape: true }) + const context = buildTemplateContext(config) + return template(context) +} + +/** + * Check if a template file exists (useful for optional templates). + */ +export function templateExists(templatePath: string): boolean { + return fs.existsSync(path.join(TEMPLATE_ROOT, templatePath)) +} + +export { TEMPLATE_ROOT } diff --git a/cli/src/utils/analytics.ts b/cli/src/utils/analytics.ts new file mode 100644 index 0000000..1355796 --- /dev/null +++ b/cli/src/utils/analytics.ts @@ -0,0 +1,78 @@ +import crypto from 'crypto' +import type { FlutterInitConfig } from '../config' + +export interface TrackPayload { + session_id: string + architecture: string + state_mgmt: string + backend_provider: string + navigation: string + networking: string + dark_mode: boolean + features: string[] +} + +/** + * Uploads project generation statistics to the backend. + * Best effort only — timeouts in 2 seconds and fails silently to prevent blocking CLI execution. + */ +export async function trackCliGeneration(config: FlutterInitConfig): Promise { + try { + const features: string[] = [] + if (config.useLocalization) features.push('localization') + if (config.usesPermissionHandler) features.push('permissions') + if (config.usesGeolocator) features.push('geolocation') + if (config.usesFilePicker) features.push('file_picker') + if (config.usesLogger) features.push('logger') + if (config.usesImagePicker) features.push('image_picker') + if (config.usesSharePlus) features.push('share_plus') + + let networking = 'none' + if (config.usesDio) { + networking = 'dio' + } else if (config.usesHttp) { + networking = 'http' + } + + let navigation = 'imperative' + if (config.navigation === 'gorouter') { + navigation = 'go_router' + } else if (config.navigation === 'autoroute') { + navigation = 'auto_route' + } + + const payload: TrackPayload = { + session_id: crypto.randomUUID(), + architecture: config.architecture, + state_mgmt: config.stateManager, + backend_provider: config.backend, + navigation, + networking, + dark_mode: config.themeMode === 'dark' || config.themeMode === 'both', + features, + } + + const baseUrl = process.env.FLUTTERINIT_API_URL || process.env.NEXT_PUBLIC_APP_URL || 'https://flutterinit.com' + const trackUrl = `${baseUrl.replace(/\/$/, '')}/api/track` + + if (typeof fetch === 'function') { + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), 2000) + + try { + await fetch(trackUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + signal: controller.signal, + }) + } finally { + clearTimeout(timeoutId) + } + } + } catch (error) { + // Fail silently: telemetry must never interrupt CLI operations. + } +} diff --git a/cli/src/utils/exec.ts b/cli/src/utils/exec.ts new file mode 100644 index 0000000..35b6ea8 --- /dev/null +++ b/cli/src/utils/exec.ts @@ -0,0 +1,30 @@ +// ───────────────────────────────────────────────────────────────────────────── +// FlutterInit CLI — Exec Utilities +// Wrappers around execSync with consistent error handling. +// Use exec() for silent commands. Use execVisible() for user-facing output. +// ───────────────────────────────────────────────────────────────────────────── + +import { execSync } from 'child_process' + +/** + * Run a command silently, capturing stdout/stderr as a string. + * Throws if the command exits with a non-zero code. + */ +export function exec(command: string, options?: { cwd?: string }): string { + return execSync(command, { + stdio: 'pipe', + cwd: options?.cwd, + encoding: 'utf-8', + }) as string +} + +/** + * Run a command with stdio inherited — the user sees the full output in their + * terminal. Used for `flutter doctor` where transparency is intentional. + */ +export function execVisible(command: string, options?: { cwd?: string }): void { + execSync(command, { + stdio: 'inherit', + cwd: options?.cwd, + }) +} diff --git a/cli/src/utils/fs.ts b/cli/src/utils/fs.ts new file mode 100644 index 0000000..97ce361 --- /dev/null +++ b/cli/src/utils/fs.ts @@ -0,0 +1,64 @@ +// ───────────────────────────────────────────────────────────────────────────── +// FlutterInit CLI — Filesystem Utilities +// File write helpers, .gitkeep creation, and cleanup on error. +// ───────────────────────────────────────────────────────────────────────────── + +import fs from 'fs' +import path from 'path' + +/** + * Write content to a file, creating any missing parent directories. + */ +export function writeFile(filePath: string, content: string): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }) + fs.writeFileSync(filePath, content, 'utf-8') +} + +/** + * Create a directory with a .gitkeep file inside so it's tracked by git. + */ +export function createGitkeep(dirPath: string): void { + fs.mkdirSync(dirPath, { recursive: true }) + const keepFile = path.join(dirPath, '.gitkeep') + if (!fs.existsSync(keepFile)) { + fs.writeFileSync(keepFile, '', 'utf-8') + } +} + +/** + * Recursively remove a directory. Used for cleanup when generation fails. + * Silently ignores errors if the directory doesn't exist. + */ +export function removeDir(dirPath: string): void { + try { + fs.rmSync(dirPath, { recursive: true, force: true }) + } catch { + // Ignore — directory may not exist yet + } +} + +/** + * Check if a directory exists and is non-empty. + */ +export function isDirNonEmpty(dirPath: string): boolean { + if (!fs.existsSync(dirPath)) return false + const entries = fs.readdirSync(dirPath) + return entries.length > 0 +} + +/** + * Copy a directory recursively from src to dest. + */ +export function copyDir(src: string, dest: string): void { + fs.mkdirSync(dest, { recursive: true }) + const entries = fs.readdirSync(src, { withFileTypes: true }) + for (const entry of entries) { + const srcPath = path.join(src, entry.name) + const destPath = path.join(dest, entry.name) + if (entry.isDirectory()) { + copyDir(srcPath, destPath) + } else { + fs.copyFileSync(srcPath, destPath) + } + } +} diff --git a/cli/src/utils/logger.ts b/cli/src/utils/logger.ts new file mode 100644 index 0000000..9623167 --- /dev/null +++ b/cli/src/utils/logger.ts @@ -0,0 +1,114 @@ +// ───────────────────────────────────────────────────────────────────────────── +// FlutterInit CLI — Branded Logger +// ASCII banner in #027DFD blue, styled section headers, and consistent output. +// ───────────────────────────────────────────────────────────────────────────── + +import { log } from '@clack/prompts' +import process from 'node:process' +import pc from 'picocolors' + +// FlutterInit brand blue: #027DFD → closest ANSI approximation via picocolors cyan/blue +const brand = (str: string) => pc.bold(pc.blue(str)) +const dim = (str: string) => pc.dim(str) +const accent = (str: string) => pc.cyan(str) +const success = (str: string) => pc.green(str) +const warn = (str: string) => pc.yellow(str) +const error = (str: string) => pc.red(str) + +// ─── Unicode Detection ──────────────────────────────────────────────────────── +export function isUnicodeSupported(): boolean { + if (process.platform !== 'win32') { + return process.env.TERM !== 'linux' // Linux console (kernel) + } + + return Boolean(process.env.WT_SESSION) // Windows Terminal + || Boolean(process.env.TERMINUS_SUBLIME) // Terminus (<0.2.27) + || process.env.ConEmuTask === '{cmd::Cmder}' // ConEmu and cmder + || process.env.TERM_PROGRAM === 'Terminus-Sublime' + || process.env.TERM_PROGRAM === 'vscode' + || process.env.TERM === 'xterm-256color' + || process.env.TERM === 'alacritty' + || process.env.TERMINAL_EMULATOR === 'JetBrains-JediTerm' +} + +// ─── ASCII Logo ─────────────────────────────────────────────────────────────── +// Rendered from the FlutterInit "F" mark + logotype + +const BANNER_UNICODE = ` +${brand(' ███████╗██╗ ██╗ ██╗████████╗████████╗███████╗██████╗ ██╗███╗ ██╗██╗████████╗')} +${brand(' ██╔════╝██║ ██║ ██║╚══██╔══╝╚══██╔══╝██╔════╝██╔══██╗ ██║████╗ ██║██║╚══██╔══╝')} +${brand(' █████╗ ██║ ██║ ██║ ██║ ██║ █████╗ ██████╔╝ ██║██╔██╗ ██║██║ ██║ ')} +${brand(' ██╔══╝ ██║ ██║ ██║ ██║ ██║ ██╔══╝ ██╔══██╗ ██║██║╚██╗██║██║ ██║ ')} +${brand(' ██║ ███████╗╚██████╔╝ ██║ ██║ ███████╗██║ ██║ ██║██║ ╚████║██║ ██║ ')} +${brand(' ╚═╝ ╚══════╝ ╚═════╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝ ')} + + ${dim('Scaffold production-ready Flutter projects from your terminal')} + ${dim('─────────────────────────────────────────────────────────────')} + ${dim('v0.1.0')} ${pc.bold('·')} ${accent('flutterinit.com')} ${pc.bold('·')} ${dim('by Arjun Mahar')} +` + +const BANNER_ASCII = ` +${brand(' ####### ## ## ## ######## ######## ####### ###### ## ### ## ## ########')} +${brand(' ## ## ## ## ## ## ## ## ## ## #### ## ## ## ')} +${brand(' ##### ## ## ## ## ## ##### ###### ## ## ## ## ## ## ')} +${brand(' ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ')} +${brand(' ## ####### ###### ## ## ####### ## ## ## ## #### ## ## ')} + + ${dim('Scaffold production-ready Flutter projects from your terminal')} + ${dim('-------------------------------------------------------------')} + ${dim('v0.1.0')} ${pc.bold('-')} ${accent('flutterinit.com')} ${pc.bold('-')} ${dim('by Arjun Mahar')} +` + +/** + * Print the FlutterInit branded ASCII banner. + * Call this before `intro()`. + */ +export function printBanner(): void { + console.log(isUnicodeSupported() ? BANNER_UNICODE : BANNER_ASCII) +} + +/** + * Print a styled section header before a group of prompts. + * Explains what the user is about to configure. + * + * @param title Short section name + * @param detail One-line explanation shown below the title + */ +export function printStep(title: string, detail: string): void { + const bullet = isUnicodeSupported() ? '◆' : '>' + console.log() + console.log(` ${brand(bullet)} ${pc.bold(title)}`) + console.log(` ${dim(detail)}`) + console.log() +} + +/** + * Print a success message (outside of a spinner context). + */ +export function logSuccess(message: string): void { + log.success(success(message)) +} + +/** + * Print a warning message. + */ +export function logWarn(message: string): void { + log.warn(warn(message)) +} + +/** + * Print an error message. + */ +export function logError(message: string): void { + log.error(error(message)) +} + +/** + * Print a branded info message with context. + */ +export function logInfo(message: string): void { + log.info(dim(message)) +} + +export { accent, brand, dim, error, success, warn } + diff --git a/cli/templates/base/.cursor/rules/flutter-project.mdc.hbs b/cli/templates/base/.cursor/rules/flutter-project.mdc.hbs new file mode 100644 index 0000000..dd0dc25 --- /dev/null +++ b/cli/templates/base/.cursor/rules/flutter-project.mdc.hbs @@ -0,0 +1,132 @@ +--- +description: {{appName}} — FlutterInit stack, architecture, design conventions, and agent zones +alwaysApply: true +--- + +# {{appName}} — Cursor project context + +{{#if description}}{{description}}{{/if}} + +Generated by FlutterInit. Package: `{{packageId}}`. + +**Extended docs (read when changing setup or deep UI):** + +- **[AGENTS.md](AGENTS.md)** — full agent guide (duplicate of sections below + packages list) +- **[DESIGN.md](DESIGN.md)** — complete design system +- **[SETUP.md](SETUP.md)** — env, Firebase/Supabase/Appwrite, native config, permissions + +--- + +{{> llm/stack-summary}} + +--- + +{{> llm/architecture-rules}} + +--- + +{{> llm/state-management-rules}} + +--- + +{{> llm/navigation-rules}} + +--- + +{{#unless (eq backend.provider "none")}} +{{> llm/backend-rules}} + +--- +{{/unless}} + +{{> llm/networking-rules}} + +--- + +{{> llm/services-conventions}} + +--- + +{{> llm/design-quick-reference}} + +--- + +{{> llm/add-feature-workflow}} + +--- + +## File zones + +### Safe to modify + +| Path | Guidance | +|------|----------| +{{#if (or (eq architecture "clean") (eq architecture "feature-first"))}}| `lib/src/features/**` | Feature screens, widgets, domain/data/presentation |{{/if}} +{{#if (eq architecture "mvc")}}| `lib/src/views/**`, `lib/src/controllers/**` | UI and controllers |{{/if}} +{{#if (eq architecture "mvvm")}}| `lib/src/ui/**`, `lib/src/data/**` | UI and data for features |{{/if}} +{{#if (eq architecture "layer-first")}}| `lib/src/presentation/**`, `lib/src/domain/**`, `lib/src/data/**` | Layer slices |{{/if}} +| `lib/src/shared/widgets/**` | Shared UI components | +| `test/**` | Tests | + +### Modify with caution + +| Path | Why | +|------|-----| +| `lib/src/routing/app_router.dart` | All navigation | +| `lib/main.dart` | Init order (SDKs, dotenv, l10n) | +| `lib/src/config/app_config.dart` | Dio / SDK clients | +| `lib/src/theme/**` | Global design tokens | +| `lib/src/imports/**` | Barrel exports | +| `pubspec.yaml` | Dependencies | +{{#if flags.usesDotenv}}| `.env` | Secrets |{{/if}} + +### Do not touch + +| Path | Why | +|------|-----| +| `android/`, `ios/`, `web/`, desktop native trees | Platform config | +{{#if flags.usesDotenv}}| `.env` with production secrets | Security |{{/if}} +{{#if flags.usesFirebase}}| `google-services.json`, `GoogleService-Info.plist` | Firebase credentials |{{/if}} + +--- + +{{> llm/packages-list}} + +--- + +## Quick rules (always enforce) + +- Import: `package:{{flags.appSlug}}/src/imports/imports.dart` +- Services: `ClassName.instance` + `runTask()` → `FutureEither`; use `rootContext`, not `BuildContext` in services +- UI: `context.colors`, `context.textTheme`, `AppSpacing`, `AppBorders` — no hardcoded hex or magic padding +- Routes: only `lib/src/routing/app_router.dart` +- Network/backend: `lib/src/services/` + `app_config.dart` — never from widgets +- No second state-management or routing library +- No business logic in `build()`; no empty `catch` blocks +{{#if flags.isRiverpod}}- Riverpod: `ref.watch` in build only; `ref.read` in callbacks; no `@riverpod` codegen{{/if}} +{{#if flags.isBloc}}- Bloc: immutable state/events; delegate logic out of handlers{{/if}} +{{#if flags.isProvider}}- Provider: `watch` in build, `read` in callbacks{{/if}} +{{#if flags.isGetX}}- GetX: bindings; no GoRouter/AutoRoute mix{{/if}} +{{#if flags.isMobX}}- MobX: mutate via `@action`; run `build_runner` after store edits{{/if}} +{{#if flags.supportsLocalization}}- Copy: `easy_localization` + `assets/translations/*.json`{{/if}} + +--- + +## Verify after changes + +```bash +flutter pub get +flutter analyze +``` + +{{> llm/build-runner-note}} + +{{#if flags.supportsLocalization}} +```bash +flutter pub run easy_localization:generate -S assets/translations -O lib/src/core/i18n -o locale_keys.g.dart +``` +{{/if}} + +```bash +flutter test +``` diff --git a/cli/templates/base/.gitignore b/cli/templates/base/.gitignore new file mode 100644 index 0000000..dbed741 --- /dev/null +++ b/cli/templates/base/.gitignore @@ -0,0 +1,12 @@ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +build/ +pubspec.lock +ios/ +android/ +web/ +macos/ +windows/ +linux/ diff --git a/cli/templates/base/AGENTS.md.hbs b/cli/templates/base/AGENTS.md.hbs new file mode 100644 index 0000000..e5973ad --- /dev/null +++ b/cli/templates/base/AGENTS.md.hbs @@ -0,0 +1,137 @@ +# Agent guide — {{appName}} + +{{> llm/stack-summary}} + +--- + +{{> llm/architecture-rules}} + +--- + +{{> llm/state-management-rules}} + +--- + +{{> llm/navigation-rules}} + +--- + +{{#unless (eq backend.provider "none")}} +{{> llm/backend-rules}} + +--- +{{/unless}} + +{{> llm/networking-rules}} + +--- + +{{> llm/services-conventions}} + +--- + +{{> llm/packages-list}} + +--- + +{{> llm/add-feature-workflow}} + +--- + +## Safe to modify + +| Path | Guidance | +|------|----------| +{{#if (or (eq architecture "clean") (eq architecture "feature-first"))}}| `lib/src/features/**` | Feature screens, widgets, domain/data/presentation code |{{/if}} +{{#if (eq architecture "mvc")}}| `lib/src/views/**`, `lib/src/controllers/**` | UI and controllers for your features |{{/if}} +{{#if (eq architecture "mvvm")}}| `lib/src/ui/**`, `lib/src/data/**` | UI and data layer for your features |{{/if}} +{{#if (eq architecture "layer-first")}}| `lib/src/presentation/**`, `lib/src/domain/**`, `lib/src/data/**` | Layer slices for new behavior |{{/if}} +| `lib/src/shared/widgets/**` | Reusable UI components | +| `lib/src/shared/helpers/**` | App-wide helpers (when not generated-only) | +| `test/**` | Unit and widget tests | +| `README.md` | Project documentation | + +## Modify with caution + +| Path | Why | +|------|-----| +| `lib/src/routing/app_router.dart` | Central navigation — breaks deep links if wrong | +| `lib/main.dart` | Initialization order (Firebase, dotenv, localization, flavors) | +| `lib/src/config/app_config.dart` | SDK and HTTP client setup | +| `lib/src/theme/**` | Global visual system | +| `lib/src/imports/**` | Barrel exports — keep pattern consistent | +| `pubspec.yaml` | Dependency graph for the whole app | +{{#if flags.usesDotenv}}| `.env` | Secrets — never commit real values |{{/if}} + +## Do not touch + +| Path | Why | +|------|-----| +| `android/`, `ios/`, `web/`, desktop folders | Native project configuration | +{{#if flags.usesDotenv}}| `.env` with real secrets | Security |{{/if}} +{{#if flags.usesFirebase}}| `google-services.json`, `GoogleService-Info.plist` | Environment-specific credentials |{{/if}} + +--- + +## Verification after changes + +```bash +flutter pub get +flutter analyze +``` + +{{> llm/build-runner-note}} + +{{#if flags.supportsLocalization}} +### Localization + +After editing `assets/translations/*.json`: + +```bash +flutter pub run easy_localization:generate -S assets/translations -O lib/src/core/i18n -o locale_keys.g.dart +``` +{{/if}} + +```bash +flutter test +``` + +--- + +{{#if flags.usesPermissionHandler}} +## Native Permissions + +This project uses `permission_handler` to manage runtime permissions. + +Configured permissions: +{{#if flags.usesCamera}}- Camera and photo library (Android + iOS){{/if}} +{{#if flags.usesImagePicker}}- Image picker (camera + gallery access){{/if}} +{{#if flags.usesFilePicker}}- File system / document picker{{/if}} +{{#if flags.usesGeolocator}}- Location (fine + coarse on Android, WhenInUse + Always on iOS){{/if}} +{{#if flags.usesNotifications}}- Local notifications (POST_NOTIFICATIONS on Android 13+){{/if}} + +### Permission Request Pattern + +Always check permission status before requesting: + +```dart +final status = await Permission.camera.status; +if (status.isDenied) { + await Permission.camera.request(); +} +``` + +Never request permissions on app launch. Request contextually — when the +user triggers the feature that needs the permission. + +--- + +{{/if}} +## Hard limits + +- Do not disable `flutter_lints` rules without an explanatory comment. +- Do not call backend or networking code directly from widgets. +- Do not commit `.env` files containing production secrets. +- Do not introduce a second state-management or routing library. +- For platform setup and API keys, use **[SETUP.md](SETUP.md)**. +- For UI tokens and spacing, use **[DESIGN.md](DESIGN.md)**. diff --git a/cli/templates/base/DESIGN.md.hbs b/cli/templates/base/DESIGN.md.hbs new file mode 100644 index 0000000..a9fe0aa --- /dev/null +++ b/cli/templates/base/DESIGN.md.hbs @@ -0,0 +1,149 @@ +# Design system — {{appName}} + +This file documents the design conventions established at generation time. Consult it before changing UI code. + +--- + +## Theme overview + +- **Preset:** `{{theme.preset}}`{{#if flags.isCupertino}} (Cupertino-style widgets where applicable){{/if}} +- **Material 3:** {{#unless flags.isCupertino}}Primary app chrome uses Material 3 theming in `lib/src/theme/theme.dart`.{{else}}Cupertino preset — still uses shared tokens from `lib/src/theme/`.{{/unless}} +- **Dark mode:** {{#if flags.hasDarkMode}}Light and dark themes are supported{{#if theme.darkMode.system}}; app can follow system brightness{{/if}}. Use semantic colors — do not branch on brightness in widgets for basic surfaces.{{else}}Light mode only — avoid hardcoded colors that will not adapt if dark mode is added later.{{/if}} +- **Customization:** Global `ThemeData` lives in `lib/src/theme/theme.dart` — avoid one-off `ThemeData` overrides in feature widgets. + +{{#if theme.primaryColor}} +- **Seed color:** `{{theme.primaryColor}}` (used to derive `ColorScheme` via `ColorScheme.fromSeed` where applicable). +{{/if}} + +--- + +## Color system + +- Use `context.colors` (`ColorScheme`) for standard Material roles: `primary`, `onPrimary`, `secondary`, `surface`, `onSurface`, `error`, etc. +- Use `context.appColors` for semantic extensions: `success`, `warning`, `info`, and their `on*` / container variants (see `lib/src/theme/color_schemes.dart`). +- Definitions: `lib/src/theme/color_schemes.dart`, applied through `lib/src/theme/theme.dart`. +- **Do not** hardcode hex colors in widgets — use theme roles or `appColors`. + +--- + +## Typography + +- Use `context.textTheme` (alias `context.typography`) for all text styles. +- Roles follow Material 3: `display*`, `headline*`, `title*`, `body*`, `label*`. +{{#if flags.hasCustomFonts}} +### Custom fonts + +Primary family: **{{flags.primaryFontFamily}}** + +{{#each flags.fontFamilies}} +- **{{family}}** — {{fonts.length}} file(s) configured in `pubspec.yaml`{{#if @first}} (app-wide `fontFamily` when primary){{/if}} +{{/each}} + +Do not set raw `fontFamily:` strings in widgets — use `textTheme` roles or theme configuration in `lib/src/theme/text_theme.dart`. +{{else}} +- No custom fonts uploaded — platform default typography (Roboto / SF Pro) via Material/Cupertino theme. +{{/if}} + +--- + +## Spacing, borders, motion + +Import tokens via `package:{{flags.appSlug}}/src/theme/theme_constants.dart`. + +| Token class | Purpose | +|-------------|---------| +| `AppSpacing` | Padding, gaps (`xxs` … `xxxl`, plus `pagePadding`, `itemGap`, `cardPadding`) | +| `AppBorders` | Border radii (`xs` … `xl`, `button`, `card`, `input`, `dialog`, …) | +| `AppShadows` | Elevation shadows (`none`, `subtle`, `card`, `elevated`, `modal`) | +| `AppDurations` | Animation durations (`fast`, `normal`, `medium`, `slow`, …) | +| `AppCurves` | Standard curves (`standard`, `emphasized`, `pageEnter`, …) | + +**Rules** + +- Do not use magic numbers for padding — use `AppSpacing`. +- Do not use inline `BorderRadius.circular(n)` — use `AppBorders`. +- Prefer Material 3 tonal elevation on `Card` / `Surface` — custom shadows only via `AppShadows`. + +{{#if flags.usesScreenutil}} +--- + +## Responsive scaling (ScreenUtil) + +- `ScreenUtilInit` is already applied in the app wrapper — do not add another. +- Design size baseline: **390×844** (iPhone 14 class). +- In templates and new code, use the `res` convention: widths `.w`, heights `.h`, radius `.r`, font sizes `.sp` when ScreenUtil is enabled. +- ScreenUtil values are runtime — avoid `const` widgets that depend on `.w` / `.sp`. +{{else}} +--- + +## Responsive layout (no ScreenUtil) + +- Use `MediaQuery`, `LayoutBuilder`, `Flexible`, and `Expanded` for responsiveness. +- Fixed pixel sizes are acceptable only for elements that should not scale (e.g. icon touch targets defined by tokens). +{{/if}} + +--- + +## Context extensions + +Defined in `lib/src/extensions/context_extension.dart`: + +| Member | Use | +|--------|-----| +| `context.colors` | `ColorScheme` | +| `context.textTheme` / `context.typography` | Text styles | +| `context.appColors` | Semantic success/warning/info | +| `context.designTokens` | Theme extension tokens | +| `context.isDarkMode` | Brightness check | +| `context.width` / `context.height` | Screen size | +| `context.showAppDialog` | Dialog helper | +| `context.showTypedSnackBar` | Status snackbars | + +Prefer these shortcuts over repeating `Theme.of(context)` in widgets. + +--- + +## Component conventions + +- Reusable widgets belong in `lib/src/shared/widgets/`. +- Default style parameters to theme values, not hardcoded colors. +- Widget file names match the widget class (`primary_button.dart` → `PrimaryButton`). +- Do not read theme inside `const` constructors. + +{{#if flags.hasDarkMode}} +### Dark mode checklist + +- No raw `Colors.white` / `Colors.black` for surfaces — use `colorScheme` roles. +- Verify new screens in both light and dark theme. +{{/if}} + +{{#if flags.supportsLocalization}} +--- + +## Localization + +- User-visible strings use `easy_localization` — files in `assets/translations/`. +- Do not hardcode display strings in widgets. +- Add keys to JSON translation files, then run the generate command in **SETUP.md**. +- Use interpolation in JSON — do not concatenate translated strings in Dart. +{{/if}} + +--- + +## Do / Don't + +**Do** + +- `context.colors` / `context.appColors` for color +- `context.textTheme` for typography +- `AppSpacing`, `AppBorders`, `AppShadows`, `AppDurations`, `AppCurves` for layout and motion +- `showAppDialog` and shared widgets for consistent UX + +**Don't** + +- Hardcode hex colors or arbitrary font families in widgets +- Magic padding/radius numbers +- Duplicate theme definitions per screen +- Bypass design tokens for one-off “quick” UI + +For agent workflows and architecture boundaries, see **[AGENTS.md](AGENTS.md)**. diff --git a/cli/templates/base/README.md.hbs b/cli/templates/base/README.md.hbs new file mode 100644 index 0000000..c935558 --- /dev/null +++ b/cli/templates/base/README.md.hbs @@ -0,0 +1,19 @@ +# {{appName}} + +Generated with the Flutter Scaffolding Wizard. + +## What's inside +- Opinionated theme with Material 3 +- Onboarding presentation starter +- Routing scaffold {{#if flags.routerPackage}}using `{{flags.routerPackage}}`{{else}}with `MaterialApp` routes{{/if}} +- {{> state_label}} +- Backend: {{backend.provider}} + +## Getting started +```bash +flutter pub get +{{#if (or flags.usesJsonSerializable (or flags.isMobX (eq flags.routerPackage "auto_route")))}} +flutter pub run build_runner build --delete-conflicting-outputs +{{/if}} +flutter run +``` \ No newline at end of file diff --git a/cli/templates/base/SETUP.md.hbs b/cli/templates/base/SETUP.md.hbs new file mode 100644 index 0000000..a11e06c --- /dev/null +++ b/cli/templates/base/SETUP.md.hbs @@ -0,0 +1,253 @@ +# 🎉 Welcome to {{appName}}! + +This project was generated dynamically based on your specific requirements. Before running your app for the first time, follow this brief setup guide to configure the required environment variables, permissions, and dependencies locally. + +--- + +## 1. 📦 Initial Dependencies & Generation + +First, fetch all pub packages: +```bash +flutter pub get +``` + +### Code Generation +{{#if (or flags.isMobX (eq flags.routerPackage "auto_route") flags.usesFirebase flags.usesHive)}} +Your stack relies on code generation ({{#if flags.usesHive}}Hive Adapters, {{/if}}{{#if flags.isMobX}}MobX, {{/if}}{{#if (eq flags.routerPackage "auto_route")}}Auto Route, {{/if}}{{#if flags.usesFirebase}}Firebase, {{/if}}etc.). Run the following command right away to generate the necessary files: +```bash +dart run build_runner build --delete-conflicting-outputs +``` +{{else}} +*(No initial code generation required for your main stack)* +{{/if}} + +### Localization +{{#if flags.supportsLocalization}} +Since you chose to include `easy_localization`, run these commands whenever you add or modify string translations to generate your keys correctly: +```bash +flutter pub run easy_localization:generate -S assets/translations -O lib/src/core/i18n -o locale_keys.g.dart +``` +{{else}} +*(Localization is not enabled for this project)* +{{/if}} + +--- + +## 2. 🎨 Native Splash Screen Setup + +{{#if flags.usesFlutterNativeSplash}} +This project uses `flutter_native_splash`. + +**To apply your custom app launch screen:** +1. Place your transparent splash logo at [`assets/images/splash.png`](assets/images/splash.png). +2. Open [`flutter_native_splash.yaml`](flutter_native_splash.yaml) in the root of your project. +3. Uncomment the `image:` paths so it looks like: + ```yaml + flutter_native_splash: + color: "#{{theme.primaryColor}}" + image: assets/images/splash.png + android_12: + image: assets/images/splash.png + icon_background_color: "#{{theme.primaryColor}}" + ``` +4. Apply the native configuration natively by running: +```bash +dart run flutter_native_splash:create --path=flutter_native_splash.yaml +``` +*Note: Run this command every time you change your splash image or background color.* +{{else}} +*(Native splash screen generation is disabled for this project)* +{{/if}} + +--- + +## 3. 🔐 App Permissions (Android & iOS) + +Based on your chosen flags (e.g. {{#if flags.usesImagePicker}}Image Picker{{/if}} {{#if flags.usesGeolocator}}, Geolocator{{/if}} {{#if flags.usesFilePicker}}, File Picker{{/if}}), you must configure Native permissions before testing these features. + +### Android Setup +Open [`android/app/src/main/AndroidManifest.xml`](android/app/src/main/AndroidManifest.xml) and add the following required permissions inside the `` tag, directly above the `` block: + +```xml + + + + +{{#if flags.usesImagePicker}} + + + + +{{/if}} +{{#if flags.usesFilePicker}} + + +{{/if}} +{{#if flags.usesGeolocator}} + + + +{{/if}} +``` + +### iOS Setup +First, describe why you need permissions. Open [`ios/Runner/Info.plist`](ios/Runner/Info.plist) and add the following inside the main `` block: + +```xml + + ... +{{#if flags.usesImagePicker}} + + NSCameraUsageDescription + This app requires access to the camera to take profile photos. + NSPhotoLibraryUsageDescription + This app requires access to the photo library to upload images. +{{/if}} +{{#if flags.usesFilePicker}} + + NSAppleMusicUsageDescription + This app requires access to media to upload files. +{{/if}} +{{#if flags.usesGeolocator}} + + NSLocationWhenInUseUsageDescription + This app requires access to location to provide localized content. + NSLocationAlwaysUsageDescription + This app requires access to location in the background. +{{/if}} + +``` + +{{#if flags.usesPermissionHandler}} +**IMPORTANT: Podfile Configuration** +Since you've enabled `permission_handler`, you **MUST** configure your requested iOS permissions using Podfile macros to comply with App Store rules. + +Open [`ios/Podfile`](ios/Podfile), find the `post_install` block at the bottom, and replace it completely with this snippet: + +```ruby +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + + target.build_configurations.each do |config| + config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [ + '$(inherited)', + + ## dart: PermissionGroup.camera + 'PERMISSION_CAMERA={{#if flags.usesImagePicker}}1{{else}}0{{/if}}', + + ## dart: PermissionGroup.photos + 'PERMISSION_PHOTOS={{#if flags.usesImagePicker}}1{{else}}0{{/if}}', + + ## dart: PermissionGroup.location + 'PERMISSION_LOCATION={{#if flags.usesGeolocator}}1{{else}}0{{/if}}', + 'PERMISSION_LOCATION_WHENINUSE={{#if flags.usesGeolocator}}1{{else}}0{{/if}}', + + ## dart: PermissionGroup.mediaLibrary + 'PERMISSION_MEDIA_LIBRARY={{#if flags.usesFilePicker}}1{{else}}0{{/if}}', + + ## dart: PermissionGroup.microphone (Usually 1 if video recording is active with image_picker) + 'PERMISSION_MICROPHONE={{#if flags.usesImagePicker}}1{{else}}0{{/if}}', + + ## Unused permissions forcefully disabled to avoid App Store Rejection + 'PERMISSION_EVENTS=0', + 'PERMISSION_EVENTS_FULL_ACCESS=0', + 'PERMISSION_REMINDERS=0', + 'PERMISSION_CONTACTS=0', + 'PERMISSION_SPEECH_RECOGNIZER=0', + 'PERMISSION_NOTIFICATIONS=0', + 'PERMISSION_SENSORS=0', + 'PERMISSION_BLUETOOTH=0', + 'PERMISSION_APP_TRACKING_TRANSPARENCY=0', + 'PERMISSION_CRITICAL_ALERTS=0', + 'PERMISSION_ASSISTANT=0', + ] + end + end +end +``` +{{/if}} + +--- + +## 4. 🌍 Environment Variables + +Your project relies on `flutter_dotenv` to load secrets. + +1. Create a [`.env`](.env) file in the project root folder. +2. Insert your required variables (e.g. `API_URL=https://api.example.com`). +3. These keys are now accessible in Dart via `dotenv.env['API_URL']`. + +--- + +## 5. 💾 Local Storage (Hive) + +{{#if flags.usesHive}} +Your project uses **Hive CE** (Community Edition) for high-performance local NoSQL storage. + +- **Automatic Init:** Hive is automatically initialized at startup in `lib/main.dart`. +- **Adapters:** When creating custom data models, use `@HiveType` and `@HiveField` annotations. +- **Generation:** Whenever you add a new Hive adapter, run the [Code Generation](#code-generation) command: + ```bash + dart run build_runner build --delete-conflicting-outputs + ``` +{{else}} +*(No local storage (Hive) selected for this project)* +{{/if}} + +--- + +## 6. ☁️ Backend Connections + +{{#if (eq backend.provider "firebase")}} +You chose **Firebase** as your backend architecture. + +**Initialize Firebase natively:** +1. Ensure the [Firebase CLI](https://firebase.google.com/docs/cli) is installed and logged in (`firebase login`). +2. Run the secure configuration tool: +```bash +dart pub global activate flutterfire_cli +flutterfire configure +``` +3. This generates `lib/firebase_options.dart`. Your integration is already set up in `lib/src/app.dart` or `lib/main.dart` to securely initialize using this file! +{{else if (eq backend.provider "supabase")}} +You chose **Supabase** as your backend architecture. + +1. Create an empty project at [supabase.com](https://supabase.com/). +2. Grab your `SUPABASE_URL` and `SUPABASE_ANON_KEY` from your Project API Settings. +3. Locate the Supabase initialization inside `lib/main.dart` and bind these keys. + *(Tip: You can securely point these to `dotenv.env['SUPABASE_URL']` from your newly created `.env` file!)* +{{else if (eq backend.provider "appwrite")}} +You chose **Appwrite** as your backend architecture. + +1. Locate the `Appwrite` Initialization code inside `lib/main.dart`. +2. Ensure you apply your Project ID and Endpoint values to the client configuration. + *(Tip: You can securely point these to `dotenv.env['APPWRITE_ENDPOINT']`!)* +{{else if (eq backend.provider "custom")}} +You chose to structure your APIs using **Custom Backend**. + +Verify that your Base URL pointing to staging/production is correctly initialized inside your globally injected network class. + *(Tip: Read this via `dotenv.env['BASE_API_URL']` inside your network initializer!)* +{{else}} +*(No remote backend provider selected. The application defaults to managing state via Mock services locally).* +{{/if}} + +--- + +## 7. 🚀 Running the App + +Once everything above is verified: + +1. **For iOS Simulators/Devices**, map your native pods locally: +```bash +cd ios +pod install +cd .. +``` + +2. Run your app via VS Code, Android Studio, or CLI: +```bash +flutter run +``` + +Congratulations, and happy coding! diff --git a/cli/templates/base/analysis_options.yaml b/cli/templates/base/analysis_options.yaml new file mode 100644 index 0000000..9535eb5 --- /dev/null +++ b/cli/templates/base/analysis_options.yaml @@ -0,0 +1,88 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +analyzer: + errors: + inference_failure_on_untyped_parameter: ignore + plugins: + - custom_lint + exclude: + - "**/*.g.dart" + - "**/*.freezed.dart" + - "**/*.gr.dart" + - "**/*.config.dart" + - "**/generated/**" + - "**/build/**" + - "**/.dart_tool/**" + - "**/ios/Pods/**" + - "**/android/.gradle/**" + - "**/android/app/build/**" + - "**/android/build/**" + - "**/web/**" + - "**/test/**" + - "**/integration_test/**" + strong-mode: + implicit-casts: true + implicit-dynamic: false + language: + strict-casts: false + strict-inference: true + strict-raw-types: true + +linter: + rules: + prefer_const_constructors: true + prefer_const_declarations: true + prefer_const_literals_to_create_immutables: true + avoid_print: true + prefer_single_quotes: true + avoid_unnecessary_containers: true + avoid_web_libraries_in_flutter: true + prefer_const_constructors_in_immutables: true + prefer_final_fields: true + prefer_final_locals: true + prefer_final_in_for_each: true + prefer_for_elements_to_map_fromIterable: true + prefer_function_declarations_over_variables: true + prefer_if_elements_to_conditional_expressions: true + prefer_if_null_operators: true + prefer_initializing_formals: true + prefer_inlined_adds: true + prefer_int_literals: true + prefer_interpolation_to_compose_strings: true + prefer_is_empty: true + prefer_is_not_empty: true + prefer_is_not_operator: true + prefer_iterable_whereType: true + prefer_null_aware_operators: true + prefer_relative_imports: false + prefer_typing_uninitialized_variables: true + sort_child_properties_last: true + use_build_context_synchronously: true + use_colored_box: true + use_decorated_box: true + use_enums: true + use_full_hex_values_for_flutter_colors: true + use_function_type_syntax_for_parameters: true + use_if_null_to_convert_nulls_to_bools: true + use_is_even_rather_than_modulo: true + use_key_in_widget_constructors: true + use_late_for_private_fields_and_variables: true + use_named_constants: true + use_raw_strings: true + use_rethrow_when_possible: true + use_setters_to_change_properties: true + use_string_buffers: true + use_super_parameters: true + use_test_throws_matchers: true + use_to_and_as_if_applicable: true + valid_regexps: true + void_checks: true diff --git a/cli/templates/base/assets/.keep b/cli/templates/base/assets/.keep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/cli/templates/base/assets/.keep @@ -0,0 +1 @@ + diff --git a/cli/templates/base/assets/icons/apple.svg b/cli/templates/base/assets/icons/apple.svg new file mode 100644 index 0000000..577d242 --- /dev/null +++ b/cli/templates/base/assets/icons/apple.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/cli/templates/base/assets/icons/facebook.svg b/cli/templates/base/assets/icons/facebook.svg new file mode 100644 index 0000000..ec785b6 --- /dev/null +++ b/cli/templates/base/assets/icons/facebook.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/cli/templates/base/assets/icons/google.svg b/cli/templates/base/assets/icons/google.svg new file mode 100644 index 0000000..c4ed619 --- /dev/null +++ b/cli/templates/base/assets/icons/google.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/cli/templates/base/assets/images/.gitkeep b/cli/templates/base/assets/images/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/cli/templates/base/flutter_native_splash.yaml.hbs b/cli/templates/base/flutter_native_splash.yaml.hbs new file mode 100644 index 0000000..a3a6db1 --- /dev/null +++ b/cli/templates/base/flutter_native_splash.yaml.hbs @@ -0,0 +1,8 @@ +{{#if flags.usesFlutterNativeSplash}} +flutter_native_splash: + color: "#{{theme.primaryColor}}" + # image: assets/images/splash.png + # android_12: + # image: assets/images/splash.png + # icon_background_color: "#{{theme.primaryColor}}" +{{/if}} diff --git a/cli/templates/base/lib/main.dart.hbs b/cli/templates/base/lib/main.dart.hbs new file mode 100644 index 0000000..c6d218e --- /dev/null +++ b/cli/templates/base/lib/main.dart.hbs @@ -0,0 +1,43 @@ +import 'src/imports/core_imports.dart'; +import 'src/imports/packages_imports.dart'; +{{#if extras.flavors}} +import 'src/flavors.dart'; +{{/if}} +import 'src/app.dart'; + + +Future main() async { + {{#if flags.usesFlutterNativeSplash}} + final widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); + FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding); + {{else}} + WidgetsFlutterBinding.ensureInitialized(); + {{/if}} + + {{#if flags.supportsLocalization}} + await EasyLocalization.ensureInitialized(); + {{/if}} + await dotenv.load(fileName: '.env'); + {{#if extras.flavors}} + FlavorConfig.load(Flavor.dev); + {{/if}} + + await AppConfig.init(); + {{#if flags.usesHive}} + await HiveService.instance.init(); + {{/if}} + + runApp( + {{#if flags.supportsLocalization}} + const LocalizationWrapper( + child: {{#if (eq stateManagement "none")}}App(){{else}}StateWrapper( + child: App(), + ){{/if}}, + ), + {{else}} + {{#if (eq stateManagement "none")}}const App(){{else}}const StateWrapper( + child: App(), + ){{/if}}, + {{/if}} + ); +} \ No newline at end of file diff --git a/cli/templates/base/lib/src/app.dart.hbs b/cli/templates/base/lib/src/app.dart.hbs new file mode 100644 index 0000000..ff77c93 --- /dev/null +++ b/cli/templates/base/lib/src/app.dart.hbs @@ -0,0 +1,105 @@ +import 'package:{{flags.appSnake}}/src/imports/core_imports.dart'; + +class App extends StatelessWidget { + const App({super.key}); + + @override + Widget build(BuildContext context) { + final current = {{#if flags.isCupertino}}_buildCupertinoApp(context){{else}}_buildMaterialApp(context){{/if}}; + {{#if flags.usesScreenutil}} + return ScreenUtilWrapper(child: current); + {{else}} + return current; + {{/if}} + } + + {{#if flags.isCupertino}} + Widget _buildCupertinoApp(BuildContext context) { + return {{#if flags.isGetX}}GetCupertinoApp{{else}}CupertinoApp{{/if}}{{#if (and flags.routerPackage (not flags.isGetX))}}.router{{/if}}( + {{#if flags.isGetX}} + initialBinding: AppBindings(), + {{/if}} + {{#unless flags.routerPackage}} + navigatorKey: rootNavigatorKey, + {{/unless}} + title: '{{appName}}', + debugShowCheckedModeBanner: false, + theme: buildCupertinoTheme(primaryColorHex: '{{theme.primaryColor}}'), + {{#if (eq flags.routerPackage "getx")}} + initialRoute: AppRoutes.onboarding, + getPages: AppRouter.getPages, + {{else if flags.routerPackage}} + routerConfig: {{#if (eq flags.routerPackage "go_router")}}appRouter{{else}}AppRouter().config(){{/if}}, + {{else}} + home: const OnboardingPage(), + onGenerateRoute: AppRouter.onGenerateRoute, + {{/if}} + {{#if flags.supportsLocalization}} + localizationsDelegates: context.localizationDelegates, + supportedLocales: context.supportedLocales, + locale: context.locale, + {{/if}} + builder: (context, child) { + Widget current = child!; + {{#if flags.usesSkeletonizer}} + current = SkeletonWrapper(child: current); + {{/if}} + current = SessionListenerWrapper(child: current); + + // Ensure Material themes (tokens, widget styles like dialogs/buttons/inputs) + // are available even in Cupertino mode, as many apps use a mix of both. + {{#if flags.isCupertino}} + current = Theme( + data: {{#if flags.hasDarkMode}}{{#if theme.darkMode.system}}(MediaQuery.of(context).platformBrightness == Brightness.dark){{else}}true{{/if}}{{else}}false{{/if}} + ? buildDarkTheme(primaryColorHex: '{{theme.primaryColor}}') + : buildLightTheme(primaryColorHex: '{{theme.primaryColor}}'), + child: current, + ); + {{/if}} + + return current; + }, + ); + } + {{else}} + Widget _buildMaterialApp(BuildContext context) { + return {{#if flags.isGetX}}GetMaterialApp{{else}}MaterialApp{{/if}}{{#if (and flags.routerPackage (not (eq flags.routerPackage "getx")))}}.router{{/if}}( + {{#if flags.isGetX}} + initialBinding: AppBindings(), + {{/if}} + {{#unless flags.routerPackage}} + navigatorKey: rootNavigatorKey, + {{/unless}} + title: '{{appName}}', + debugShowCheckedModeBanner: false, + theme: buildLightTheme(primaryColorHex: '{{theme.primaryColor}}'), + {{#if flags.hasDarkMode}} + darkTheme: buildDarkTheme(primaryColorHex: '{{theme.primaryColor}}'), + themeMode: {{#if theme.darkMode.system}}ThemeMode.system{{else}}ThemeMode.dark{{/if}}, + {{/if}} + {{#if (eq flags.routerPackage "getx")}} + initialRoute: AppRoutes.onboarding, + getPages: AppRouter.getPages, + {{else if flags.routerPackage}} + routerConfig: {{#if (eq flags.routerPackage "go_router")}}appRouter{{else}}AppRouter().config(){{/if}}, + {{else}} + home: const OnboardingPage(), + onGenerateRoute: AppRouter.onGenerateRoute, + {{/if}} + {{#if flags.supportsLocalization}} + localizationsDelegates: context.localizationDelegates, + supportedLocales: context.supportedLocales, + locale: context.locale, + {{/if}} + builder: (context, child) { + Widget current = child!; + {{#if flags.usesSkeletonizer}} + current = SkeletonWrapper(child: current); + {{/if}} + current = SessionListenerWrapper(child: current); + return current; + }, + ); + } + {{/if}} +} \ No newline at end of file diff --git a/cli/templates/base/lib/src/config/app_config.dart.hbs b/cli/templates/base/lib/src/config/app_config.dart.hbs new file mode 100644 index 0000000..e2e967f --- /dev/null +++ b/cli/templates/base/lib/src/config/app_config.dart.hbs @@ -0,0 +1,112 @@ +{{#if flags.usesDio}} +import '../imports/core_imports.dart'; +{{/if}} +{{#if flags.usesDio}} +import 'package:dio/dio.dart'; +{{/if}} +{{#if flags.usesHttp}} +import 'package:http/http.dart' as http; +{{/if}} +{{#if (eq backend.provider "firebase")}} +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_auth/firebase_auth.dart'; +{{#if backend.options.firestore}}import 'package:cloud_firestore/cloud_firestore.dart';{{/if}} +{{#if backend.options.realtimeDb}}import 'package:firebase_database/firebase_database.dart';{{/if}} +{{#if backend.options.storage}}import 'package:firebase_storage/firebase_storage.dart';{{/if}} +{{/if}} +{{#if (eq backend.provider "supabase")}} +import 'package:supabase_flutter/supabase_flutter.dart'; +{{/if}} +{{#if (eq backend.provider "appwrite")}} +import 'package:appwrite/appwrite.dart'; +{{/if}} +import 'package:flutter_dotenv/flutter_dotenv.dart'; + +class AppConfig { + AppConfig._(); + {{#if flags.usesDio}} + static late final Dio dio; + {{/if}} + {{#if flags.usesHttp}} + static late final http.Client httpClient; + {{/if}} + {{#if (eq backend.provider "supabase")}} + static late final SupabaseClient supabase; + {{/if}} + {{#if (eq backend.provider "appwrite")}} + static late final Client appwriteClient; + static late final Account appwriteAccount; + {{/if}} + {{#if (eq backend.provider "firebase")}} + static FirebaseAuth get firebaseAuth => FirebaseAuth.instance; + {{#if backend.options.firestore}} + static FirebaseFirestore get firestore => FirebaseFirestore.instance; + {{/if}} + {{#if backend.options.realtimeDb}} + static FirebaseDatabase get realtimeDb => FirebaseDatabase.instance; + {{/if}} + {{#if backend.options.storage}} + static FirebaseStorage get storage => FirebaseStorage.instance; + {{/if}} + {{/if}} + + static String get baseUrl => _getBaseUrl(); + + static Future init() async { + {{#if (eq backend.provider "firebase")}} + await Firebase.initializeApp(); + {{/if}} + {{#if (eq backend.provider "supabase")}} + await Supabase.initialize( + url: dotenv.get('SUPABASE_URL', fallback: 'https://YOUR-PROJECT.supabase.co'), + publishableKey: dotenv.get('SUPABASE_ANON_KEY', fallback: 'YOUR-ANON-KEY'), + ); + supabase = Supabase.instance.client; + {{/if}} + {{#if (eq backend.provider "appwrite")}} + appwriteClient = Client() + ..setEndpoint(dotenv.get('APPWRITE_ENDPOINT', fallback: 'https://cloud.appwrite.io/v1')) + ..setProject(dotenv.get('APPWRITE_PROJECT_ID', fallback: 'your-project-id')) + ..setSelfSigned(status: true); + appwriteAccount = Account(appwriteClient); + {{/if}} + {{#if flags.usesDio}} + dio = Dio( + BaseOptions( + baseUrl: _getBaseUrl(), + connectTimeout: const Duration(seconds: 30), + receiveTimeout: const Duration(seconds: 30), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + ), + ); + + dio.interceptors.add( + InterceptorsWrapper( + onRequest: (options, handler) { + AppLogger.info('🌐 [DIO] REQUEST[${options.method}] => PATH: ${options.path}'); + return handler.next(options); + }, + onResponse: (response, handler) { + AppLogger.info('✅ [DIO] RESPONSE[${response.statusCode}] => PATH: ${response.requestOptions.path}'); + return handler.next(response); + }, + onError: (DioException e, handler) { + AppLogger.error('❌ [DIO] ERROR[${e.response?.statusCode}] => PATH: ${e.requestOptions.path}'); + return handler.next(e); + }, + ), + ); + {{/if}} + + {{#if flags.usesHttp}} + httpClient = http.Client(); + {{/if}} + } + + static String _getBaseUrl() { + return dotenv.get('API_BASE_URL', fallback: '{{#if (eq backend.provider "custom")}}{{backend.options.baseUrl}}{{else}}https://api.example.com{{/if}}'); + } +} diff --git a/cli/templates/base/lib/src/extensions/collection_extension.dart.hbs b/cli/templates/base/lib/src/extensions/collection_extension.dart.hbs new file mode 100644 index 0000000..2b3d418 --- /dev/null +++ b/cli/templates/base/lib/src/extensions/collection_extension.dart.hbs @@ -0,0 +1,55 @@ +import 'package:flutter/widgets.dart'; + +extension IterableExtension on Iterable { + // Safe accessors + T? get firstOrNull => isEmpty ? null : first; + T? get lastOrNull => isEmpty ? null : last; + + // Iteration + Iterable mapIndexed(E Function(int index, T item) f) { + var i = 0; + return map((e) => f(i++, e)); + } + + // Data manipulation + Map> groupBy(K Function(T item) keySelector) { + final groups = >{}; + for (final item in this) { + final key = keySelector(item); + groups.putIfAbsent(key, () => []).add(item); + } + return groups; + } + + Iterable> chunk(int size) { + if (size <= 0) return [toList()]; + final chunks = >[]; + final list = toList(); + for (var i = 0; i < list.length; i += size) { + chunks.add(list.sublist(i, i + size > list.length ? list.length : i + size)); + } + return chunks; + } + + List get distinct => toSet().toList(); + + List distinctBy(Object Function(T item) keySelector) { + final seen = {}; + return where((item) => seen.add(keySelector(item))).toList(); + } +} + +extension ListWidgetExtension on List { + // UI Helpers + List separatedBy(Widget separator) { + if (isEmpty) return []; + final result = []; + for (var i = 0; i < length; i++) { + result.add(this[i]); + if (i < length - 1) { + result.add(separator); + } + } + return result; + } +} diff --git a/cli/templates/base/lib/src/extensions/context_extension.dart.hbs b/cli/templates/base/lib/src/extensions/context_extension.dart.hbs new file mode 100644 index 0000000..fd156c1 --- /dev/null +++ b/cli/templates/base/lib/src/extensions/context_extension.dart.hbs @@ -0,0 +1,167 @@ +import 'package:flutter/material.dart'; +{{#if (eq flags.routerPackage "go_router")}} +import 'package:go_router/go_router.dart'; +{{/if}} + +import '../theme/color_schemes.dart'; +import '../theme/theme.dart'; +import '../shared/enums/snack_bar_type.dart'; + +extension ContextExtension on BuildContext { + // ── Theme shortcuts ────────────────────────────────────────────────────── + ThemeData get theme => Theme.of(this); + TextTheme get typography => theme.textTheme; + TextTheme get textTheme => theme.textTheme; + ColorScheme get colors => theme.colorScheme; + bool get isDarkMode => theme.brightness == Brightness.dark; + + /// Semantic/custom colors (success, warning, info). + AppColorsExtension get appColors => + theme.extension() ?? (isDarkMode ? AppPalettes.dark : AppPalettes.light); + + /// Design tokens (spacing, border radii, elevation defaults). + AppDesignTokens get designTokens => + theme.extension() ?? AppDesignTokens.fallback; + + // ── MediaQuery shortcuts ───────────────────────────────────────────────── + Size get mediaQuerySize => MediaQuery.sizeOf(this); + Size get screenSize => mediaQuerySize; + double get width => mediaQuerySize.width; + double get height => mediaQuerySize.height; + + /// Safe-area insets for the current view. + EdgeInsets get safeArea => MediaQuery.paddingOf(this); + + // ── Keyboard ────────────────────────────────────────────────────────────── + bool get isKeyboardVisible => MediaQuery.viewInsetsOf(this).bottom > 0; + void hideKeyboard() => FocusScope.of(this).unfocus(); + + // ── Platform ───────────────────────────────────────────────────────────── + bool get isIOS => theme.platform == TargetPlatform.iOS; + bool get isAndroid => theme.platform == TargetPlatform.android; + + // ── Overlays ───────────────────────────────────────────────────────────── + void showSnackBar( + String message, { + SnackBarAction? action, + Duration duration = const Duration(seconds: 3), + }) { + ScaffoldMessenger.of(this) + ..clearSnackBars() + ..showSnackBar( + SnackBar( + content: Text(message), + action: action, + duration: duration, + ), + ); + } + + void showSuccessSnackBar(String message) { + ScaffoldMessenger.of(this) + ..clearSnackBars() + ..showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: appColors.success, + ), + ); + } + + void showErrorSnackBar(String message) { + ScaffoldMessenger.of(this) + ..clearSnackBars() + ..showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: colors.error, + ), + ); + } + + Future showAppBottomSheet({ + required WidgetBuilder builder, + bool isScrollControlled = true, + bool useSafeArea = true, + }) { + return showModalBottomSheet( + context: this, + builder: builder, + isScrollControlled: isScrollControlled, + useSafeArea: useSafeArea, + ); + } + + Future showAppDialog({required WidgetBuilder builder}) { + return showDialog( + context: this, + builder: builder, + ); + } + + /// Shows a snackbar with a colour driven by [SnackBarType]. + /// + /// ```dart + /// context.showTypedSnackBar('Saved!', type: SnackBarType.success); + /// ``` + void showTypedSnackBar( + String message, { + SnackBarType type = SnackBarType.info, + Duration duration = const Duration(seconds: 3), + }) { + final bg = switch (type) { + SnackBarType.success => appColors.success, + SnackBarType.warning => appColors.warning, + SnackBarType.error => colors.error, + SnackBarType.info => colors.inverseSurface, + }; + ScaffoldMessenger.of(this) + ..clearSnackBars() + ..showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: bg, + duration: duration, + ), + ); + } + + // ── Routing shortcuts ──────────────────────────────────────────────────── + {{#if (eq flags.routerPackage "go_router")}} + String get currentRoute { + final router = GoRouter.of(this); + final RouteMatch lastMatch = router.routerDelegate.currentConfiguration.last; + final RouteMatchList matchList = lastMatch is ImperativeRouteMatch + ? lastMatch.matches + : router.routerDelegate.currentConfiguration; + return matchList.uri.toString(); + } + {{/if}} + + {{#unless flags.routerPackage}} + void pop([T? result]) => Navigator.of(this).pop(result); + + Future push(Route route) { + return Navigator.of(this).push(route); + } + + Future pushNamed( + String routeName, { + Object? arguments, + }) { + return Navigator.of(this).pushNamed(routeName, arguments: arguments); + } + + Future pushReplacementNamed( + String routeName, { + TO? result, + Object? arguments, + }) { + return Navigator.of(this).pushReplacementNamed( + routeName, + result: result, + arguments: arguments, + ); + } + {{/unless}} +} diff --git a/cli/templates/base/lib/src/extensions/date_time_extension.dart.hbs b/cli/templates/base/lib/src/extensions/date_time_extension.dart.hbs new file mode 100644 index 0000000..9e6a231 --- /dev/null +++ b/cli/templates/base/lib/src/extensions/date_time_extension.dart.hbs @@ -0,0 +1,40 @@ +extension DateTimeExtension on DateTime { + bool get isToday { + final now = DateTime.now(); + return year == now.year && month == now.month && day == now.day; + } + + bool get isYesterday { + final yesterday = DateTime.now().subtract(const Duration(days: 1)); + return year == yesterday.year && month == yesterday.month && day == yesterday.day; + } + + bool isSameDay(DateTime other) { + return year == other.year && month == other.month && day == other.day; + } + + String get toIso8601DateOnly { + return '${year.toString().padLeft(4, '0')}-${month.toString().padLeft(2, '0')}-${day.toString().padLeft(2, '0')}'; + } + + String timeAgo() { + final now = DateTime.now(); + final difference = now.difference(this); + + if (difference.inDays > 365) { + return '${(difference.inDays / 365).floor()} years ago'; + } else if (difference.inDays > 30) { + return '${(difference.inDays / 30).floor()} months ago'; + } else if (difference.inDays > 7) { + return '${(difference.inDays / 7).floor()} weeks ago'; + } else if (difference.inDays >= 1) { + return '${difference.inDays} days ago'; + } else if (difference.inHours >= 1) { + return '${difference.inHours} hours ago'; + } else if (difference.inMinutes >= 1) { + return '${difference.inMinutes} minutes ago'; + } else { + return 'just now'; + } + } +} diff --git a/cli/templates/base/lib/src/extensions/extensions.dart.hbs b/cli/templates/base/lib/src/extensions/extensions.dart.hbs new file mode 100644 index 0000000..fe82153 --- /dev/null +++ b/cli/templates/base/lib/src/extensions/extensions.dart.hbs @@ -0,0 +1,6 @@ +export 'collection_extension.dart'; +export 'context_extension.dart'; +export 'date_time_extension.dart'; +export 'num_extension.dart'; +export 'string_extension.dart'; +export 'widget_extension.dart'; diff --git a/cli/templates/base/lib/src/extensions/num_extension.dart.hbs b/cli/templates/base/lib/src/extensions/num_extension.dart.hbs new file mode 100644 index 0000000..952ac50 --- /dev/null +++ b/cli/templates/base/lib/src/extensions/num_extension.dart.hbs @@ -0,0 +1,14 @@ +import 'package:flutter/material.dart'; + +extension NumExtension on num { + // SizedBox generators + Widget get kH => SizedBox(height: toDouble()); + Widget get kW => SizedBox(width: toDouble()); + + // BorderRadius helpers + BorderRadius get radius => BorderRadius.circular(toDouble()); + + // Duration helpers + Duration get ms => Duration(milliseconds: toInt()); + Duration get seconds => Duration(seconds: toInt()); +} diff --git a/cli/templates/base/lib/src/extensions/string_extension.dart.hbs b/cli/templates/base/lib/src/extensions/string_extension.dart.hbs new file mode 100644 index 0000000..6d05b7b --- /dev/null +++ b/cli/templates/base/lib/src/extensions/string_extension.dart.hbs @@ -0,0 +1,91 @@ +extension StringExtension on String { + // Validators + bool get isValidEmail { + return RegExp( + r"^[a-zA-Z0-9.a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]+\.[a-zA-Z]+", + ).hasMatch(this); + } + + bool get isValidPhoneNumber { + if (length > 16 || length < 9) return false; + return RegExp(r'^[+]*[(]{0,1}[0-9]{1,4}[)]{0,1}[-\s\./0-9]*$').hasMatch(this); + } + + bool get isValidUrl { + return RegExp( + r"^((((H|h)(T|t)|(F|f))(T|t)(P|p)((S|s)?))\://)?(www.|[a-zA-Z0-9].)[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,6}(\:[0-9]{1,5})*(/($|[a-zA-Z0-9\.\,\;\?\'\\\+&%\$#\=~_\-]+))*$", + ).hasMatch(this); + } + + // Formatters + String get capitalizeFirst { + if (isEmpty) return this; + return this[0].toUpperCase() + substring(1).toLowerCase(); + } + + String get toTitleCase { + if (isEmpty) return this; + return split(' ').map((str) => str.capitalizeFirst).join(' '); + } + + String toK() { + final value = double.tryParse(this); + if (value == null) return this; + if (value >= 1000000) { + return '${(value / 1000000).toStringAsFixed(value % 1000000 == 0 ? 0 : 1)}M'; + } else if (value >= 1000) { + return '${(value / 1000).toStringAsFixed(value % 1000 == 0 ? 0 : 1)}k'; + } + return this; + } + + // Safe Parsers + int? get toIntOrNull => int.tryParse(this); + double? get toDoubleOrNull => double.tryParse(this); + + // Localization markers + String get hardcoded => this; + + // Aliases for validation + bool get isPhoneNumber => isValidPhoneNumber; + bool get isURL => isValidUrl; + bool get isEmail => isValidEmail; + + // Privacy helpers + String get maskEmail { + if (!isEmail) return this; + final parts = split('@'); + final name = parts[0]; + final domain = parts[1]; + if (name.length <= 2) return this; + return '${name.substring(0, 2)}****@$domain'; + } + + String maskCenter([int visibleChars = 4]) { + if (length <= visibleChars) return this; + final maskedLength = length - visibleChars; + final start = (length - maskedLength) ~/ 2; + return replaceRange(start, start + maskedLength, '*' * maskedLength); + } + + // UI Helpers + /// Converts hex string to Color object + /// Supports: #RRGGBB, RRGGBB, #AARRGGBB, AARRGGBB + dynamic toColor() { + // Note: dynamic return to avoid importing Material in this logic-only file + // The user can cast it to Color or we can import material here + var hexColor = replaceAll('#', ''); + if (hexColor.length == 6) { + hexColor = 'FF$hexColor'; + } + if (hexColor.length == 8) { + return int.parse('0x$hexColor'); + } + return null; + } +} + +extension StringOptionalExtension on String? { + bool get isNullOrEmpty => this == null || this!.trim().isEmpty; + bool get isNotNullOrEmpty => !isNullOrEmpty; +} diff --git a/cli/templates/base/lib/src/extensions/widget_extension.dart.hbs b/cli/templates/base/lib/src/extensions/widget_extension.dart.hbs new file mode 100644 index 0000000..2991872 --- /dev/null +++ b/cli/templates/base/lib/src/extensions/widget_extension.dart.hbs @@ -0,0 +1,47 @@ +import '../imports/imports.dart'; + +extension WidgetExtension on Widget { + /// Wrap the widget with Padding + Widget paddingAll(double value) => Padding( + padding: EdgeInsets.all(value), + child: this, + ); + + Widget paddingSymmetric({double horizontal = 0, double vertical = 0}) => Padding( + padding: EdgeInsets.symmetric(horizontal: horizontal, vertical: vertical), + child: this, + ); + + Widget paddingOnly({ + double left = 0, + double top = 0, + double right = 0, + double bottom = 0, + }) => + Padding( + padding: EdgeInsets.only(left: left, top: top, right: right, bottom: bottom), + child: this, + ); + + /// Wrap the widget with Opacity + Widget opacity(double value) => Opacity(opacity: value, child: this); + + /// Wrap the widget with Visibility + Widget visible(bool visible, {Widget fallback = const SizedBox.shrink()}) => + visible ? this : fallback; + + /// Wrap the widget with Center + Widget get center => Center(child: this); + + /// Wrap the widget with Expanded + Widget get expanded => Expanded(child: this); + + /// Wrap the widget with FittedBox + Widget get fitted => FittedBox(child: this); + + /// Wrap the widget with Hero + Widget hero(Object tag) => Hero(tag: tag, child: this); + + /// Wrap the widget with InkWell + Widget tooltip(String message) => Tooltip(message: message, child: this); +} diff --git a/cli/templates/base/lib/src/imports/core_imports.dart.hbs b/cli/templates/base/lib/src/imports/core_imports.dart.hbs new file mode 100644 index 0000000..0c4c750 --- /dev/null +++ b/cli/templates/base/lib/src/imports/core_imports.dart.hbs @@ -0,0 +1,50 @@ +// Flutter SDK +export 'package:flutter/material.dart'; +export 'package:flutter/cupertino.dart' hide RefreshCallback; +export 'package:flutter/foundation.dart'; +export 'package:flutter/services.dart'; +{{#if flags.usesFlutterNativeSplash}} +export 'package:flutter_native_splash/flutter_native_splash.dart'; +{{/if}} + +{{#if flags.supportsLocalization}} +export 'package:easy_localization/easy_localization.dart' hide TextDirection, MapExtension; +{{/if}} + +// Project Core — everything exported through shared.dart (theme, extensions, +// utils, widgets, enums) plus routing and services. +export '../config/app_config.dart'; +export '../routing/app_router.dart'; +export '../routing/app_routes.dart'; +export '../routing/global_navigator.dart'; +{{#if flags.isGetX}} +export '../routing/app_bindings.dart'; +{{/if}} +export '../services/services.dart'; +export '../shared/shared.dart'; + +{{#if (eq architecture "layer-first")}} +export '../presentation/screens/auth/login_screen.dart'; +export '../presentation/screens/auth/signup_screen.dart'; +export '../presentation/screens/auth/forgot_password_screen.dart'; +export '../presentation/screens/home/home_page.dart'; +export '../presentation/screens/onboarding/onboarding_page.dart'; +{{else if (eq architecture "mvc")}} +export '../views/auth/login_screen.dart'; +export '../views/auth/signup_screen.dart'; +export '../views/auth/forgot_password_screen.dart'; +export '../views/home/home_page.dart'; +export '../views/onboarding/onboarding_page.dart'; +{{else if (eq architecture "mvvm")}} +export '../ui/auth/login_screen.dart'; +export '../ui/auth/signup_screen.dart'; +export '../ui/auth/forgot_password_screen.dart'; +export '../ui/home/home_page.dart'; +export '../ui/onboarding/onboarding_page.dart'; +{{else}} +export '../features/auth/presentation/screens/login_screen.dart'; +export '../features/auth/presentation/screens/signup_screen.dart'; +export '../features/auth/presentation/screens/forgot_password_screen.dart'; +export '../features/home/presentation/screens/home_page.dart'; +export '../features/onboarding/presentation/screens/onboarding_page.dart'; +{{/if}} diff --git a/cli/templates/base/lib/src/imports/imports.dart.hbs b/cli/templates/base/lib/src/imports/imports.dart.hbs new file mode 100644 index 0000000..79a104c --- /dev/null +++ b/cli/templates/base/lib/src/imports/imports.dart.hbs @@ -0,0 +1,2 @@ +export 'core_imports.dart'; +export 'packages_imports.dart'; diff --git a/cli/templates/base/lib/src/imports/packages_imports.dart.hbs b/cli/templates/base/lib/src/imports/packages_imports.dart.hbs new file mode 100644 index 0000000..a2b0e06 --- /dev/null +++ b/cli/templates/base/lib/src/imports/packages_imports.dart.hbs @@ -0,0 +1,110 @@ +export 'package:fpdart/fpdart.dart' hide State; +export 'package:internet_connection_checker_plus/internet_connection_checker_plus.dart'; +{{#if flags.usesScreenutil}} +export 'package:flutter_screenutil/flutter_screenutil.dart'; +{{/if}} +{{#if flags.usesFlutterHooks}} +export 'package:flutter_hooks/flutter_hooks.dart'; +{{/if}} +export 'package:equatable/equatable.dart'; +{{#if flags.isRiverpod}} +export 'package:flutter_riverpod/flutter_riverpod.dart'; +export 'package:flutter_riverpod/legacy.dart'; +{{#if flags.usesFlutterHooks}} +export 'package:hooks_riverpod/hooks_riverpod.dart'; +export 'package:hooks_riverpod/legacy.dart'; +{{/if}} +{{/if}} +{{#if (or flags.isProvider flags.isMobX)}} +export 'package:provider/provider.dart' hide Dispose; +{{/if}} +{{#if flags.isBloc}} +export 'package:flutter_bloc/flutter_bloc.dart'; +{{/if}} +{{#if (or flags.isGetX (eq flags.routerPackage "getx"))}} +// For Navigation +export 'package:get/get_navigation/get_navigation.dart' hide GetContextExtensions; +// For State Management (.obs, GetxController, Obx) +{{#if flags.isGetX}} +export 'package:get/get_state_manager/get_state_manager.dart'; +// For Dependency Management (Get.put, Get.find) +export 'package:get/get_instance/get_instance.dart'; +{{/if}} +{{/if}} +{{#if flags.isMobX}} +export 'package:mobx/mobx.dart' hide version, StringExtension, Action, Listener, Listenable, Interceptor, Interceptors; +export 'package:flutter_mobx/flutter_mobx.dart' hide version; +{{/if}} +{{#if (eq flags.routerPackage "go_router")}} +export 'package:{{flags.routerPackage}}/{{flags.routerPackage}}.dart'; +{{else if (eq flags.routerPackage "auto_route")}} +export 'package:auto_route/auto_route.dart' hide kCupertinoModalBarrierColor, CupertinoPageTransition, CupertinoFullscreenDialogTransition; +{{/if}} +{{#if flags.usesDio}} +export 'package:dio/dio.dart'; +{{/if}} +{{#if flags.usesHttp}} +export 'package:http/http.dart'{{#if flags.usesDio}} hide MultipartFile, Response{{/if}}; +{{/if}} +{{#if flags.usesCachedNetworkImage}} +export 'package:cached_network_image/cached_network_image.dart'; +{{/if}} +{{#if flags.usesFlutterSvg}} +export 'package:flutter_svg/flutter_svg.dart'; +{{/if}} +{{#if flags.usesIconsaxPlus}} +export 'package:iconsax_plus/iconsax_plus.dart'; +{{/if}} +{{#if flags.usesFlutterRemix}} +export 'package:flutter_remix/flutter_remix.dart'; +{{/if}} +{{#if flags.usesHugeicons}} +export 'package:hugeicons/hugeicons.dart'; +{{/if}} +{{#if flags.usesSkeletonizer}} +export 'package:skeletonizer/skeletonizer.dart'; +{{/if}} +export 'package:flutter_animate/flutter_animate.dart' hide ShimmerEffect; +export 'package:smooth_page_indicator/smooth_page_indicator.dart' hide ScaleEffect, SlideEffect, SwapEffect; +export 'package:flutter_dotenv/flutter_dotenv.dart'; +{{#if flags.usesHive}} +export 'package:hive_ce_flutter/hive_ce_flutter.dart'; +{{/if}} +{{#if flags.usesSharedPreferences}} +export 'package:shared_preferences/shared_preferences.dart'; +{{/if}} +{{#if flags.usesSecureStorage}} +export 'package:flutter_secure_storage/flutter_secure_storage.dart'; +{{/if}} +{{#if flags.usesLogger}} +export 'package:logger/logger.dart'; +{{/if}} +{{#if flags.usesImagePicker}} +export 'package:image_picker/image_picker.dart'; +{{/if}} +{{#if flags.usesFilePicker}} +export 'package:file_picker/file_picker.dart'; +{{/if}} +{{#if flags.usesPathProvider}} +export 'package:path_provider/path_provider.dart'; +{{/if}} +{{#if flags.usesUrlLauncher}} +export 'package:url_launcher/url_launcher.dart'; +{{/if}} +{{#if flags.usesSharePlus}} +export 'package:share_plus/share_plus.dart'; +{{/if}} +{{#if flags.usesPermissionHandler}} +export 'package:permission_handler/permission_handler.dart'; +{{/if}} +{{#if flags.usesGeolocator}} +export 'package:geolocator/geolocator.dart' hide ServiceStatus; +{{/if}} +{{#if flags.usesDeviceInfoPlus}} +export 'package:device_info_plus/device_info_plus.dart'; +{{/if}} +{{#if flags.usesAppVersionUpdate}} +export 'package:app_version_update/app_version_update.dart'; +{{/if}} + + diff --git a/cli/templates/base/lib/src/routing/(isGetX)@app_bindings.dart.hbs b/cli/templates/base/lib/src/routing/(isGetX)@app_bindings.dart.hbs new file mode 100644 index 0000000..6c5b3e6 --- /dev/null +++ b/cli/templates/base/lib/src/routing/(isGetX)@app_bindings.dart.hbs @@ -0,0 +1,23 @@ +import 'package:get/get.dart'; +import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}data/repositories/{{else if (eq architecture "mvc")}}services/{{else if (eq architecture "mvvm")}}data/repositories/{{else}}features/auth/data/repositories/{{/if}}auth_repository_impl.dart'; + +{{#if (eq architecture "layer-first")}} +import 'package:{{flags.appSnake}}/src/presentation/controllers/auth/auth_controller.dart'; +{{else if (eq architecture "mvc")}} +import 'package:{{flags.appSnake}}/src/controllers/auth/auth_controller.dart'; +{{else if (eq architecture "mvvm")}} +import 'package:{{flags.appSnake}}/src/ui/auth/controllers/auth_controller.dart'; +{{else}} +import 'package:{{flags.appSnake}}/src/features/auth/presentation/controllers/auth_controller.dart'; +{{/if}} + +class AppBindings implements Bindings { + @override + void dependencies() { + {{#if flags.isGetX}} + Get.lazyPut( + () => AuthController(repository: AuthRepositoryImpl()), + ); + {{/if}} + } +} diff --git a/cli/templates/base/lib/src/routing/app_router.dart.hbs b/cli/templates/base/lib/src/routing/app_router.dart.hbs new file mode 100644 index 0000000..835311d --- /dev/null +++ b/cli/templates/base/lib/src/routing/app_router.dart.hbs @@ -0,0 +1,252 @@ +{{#if (eq flags.routerPackage "go_router")}} +import 'package:go_router/go_router.dart'; +import 'package:{{flags.appSnake}}/src/routing/global_navigator.dart'; +import 'package:{{flags.appSnake}}/src/routing/app_routes.dart'; + +{{#if (eq architecture "layer-first")}} +import 'package:{{flags.appSnake}}/src/presentation/screens/auth/login_screen.dart'; +import 'package:{{flags.appSnake}}/src/presentation/screens/auth/signup_screen.dart'; +import 'package:{{flags.appSnake}}/src/presentation/screens/auth/forgot_password_screen.dart'; +{{else if (eq architecture "mvc")}} +import 'package:{{flags.appSnake}}/src/views/auth/login_screen.dart'; +import 'package:{{flags.appSnake}}/src/views/auth/signup_screen.dart'; +import 'package:{{flags.appSnake}}/src/views/auth/forgot_password_screen.dart'; +{{else if (eq architecture "mvvm")}} +import 'package:{{flags.appSnake}}/src/ui/auth/login_screen.dart'; +import 'package:{{flags.appSnake}}/src/ui/auth/signup_screen.dart'; +import 'package:{{flags.appSnake}}/src/ui/auth/forgot_password_screen.dart'; +{{else}} +import 'package:{{flags.appSnake}}/src/features/auth/presentation/screens/login_screen.dart'; +import 'package:{{flags.appSnake}}/src/features/auth/presentation/screens/signup_screen.dart'; +import 'package:{{flags.appSnake}}/src/features/auth/presentation/screens/forgot_password_screen.dart'; +{{/if}} + +{{#if (eq architecture "layer-first")}} +import 'package:{{flags.appSnake}}/src/presentation/screens/home/home_page.dart'; +import 'package:{{flags.appSnake}}/src/presentation/screens/onboarding/onboarding_page.dart'; + +{{else if (eq architecture "mvc")}} +import 'package:{{flags.appSnake}}/src/views/home/home_page.dart'; +import 'package:{{flags.appSnake}}/src/views/onboarding/onboarding_page.dart'; + +{{else if (eq architecture "mvvm")}} +import 'package:{{flags.appSnake}}/src/ui/home/home_page.dart'; +import 'package:{{flags.appSnake}}/src/ui/onboarding/onboarding_page.dart'; + +{{else}} +import 'package:{{flags.appSnake}}/src/features/home/presentation/screens/home_page.dart'; +import 'package:{{flags.appSnake}}/src/features/onboarding/presentation/screens/onboarding_page.dart'; + +{{/if}} + +final GoRouter appRouter = GoRouter( + navigatorKey: rootNavigatorKey, + initialLocation: AppRoutes.onboarding, + routes: [ + GoRoute( + path: AppRoutes.onboarding, + name: 'onboarding', + builder: (context, state) => const OnboardingPage(), + ), + GoRoute( + path: AppRoutes.login, + name: 'login', + builder: (context, state) => const LoginScreen(), + ), + GoRoute( + path: AppRoutes.signup, + name: 'signup', + builder: (context, state) => const SignupScreen(), + ), + GoRoute( + path: AppRoutes.forgotPassword, + name: 'forgotPassword', + builder: (context, state) => const ForgotPasswordScreen(), + ), + GoRoute( + path: AppRoutes.home, + name: 'home', + builder: (context, state) => const HomePage(), + ), + ], +); +{{else if (eq flags.routerPackage "auto_route")}} +import 'package:auto_route/auto_route.dart'; +import 'package:{{flags.appSnake}}/src/routing/global_navigator.dart'; +import 'package:{{flags.appSnake}}/src/routing/app_routes.dart'; +export 'app_router.gr.dart'; +import 'app_router.gr.dart'; + +@AutoRouterConfig() +class AppRouter extends RootStackRouter { + AppRouter() : super(navigatorKey: rootNavigatorKey); + + @override + List get routes => [ + AutoRoute( + page: OnboardingRoute.page, + path: AppRoutes.onboarding, + initial: true, + ), + AutoRoute( + page: LoginRoute.page, + path: AppRoutes.login, + ), + AutoRoute( + page: SignupRoute.page, + path: AppRoutes.signup, + ), + AutoRoute( + page: ForgotPasswordRoute.page, + path: AppRoutes.forgotPassword, + ), + AutoRoute( + page: HomeRoute.page, + path: AppRoutes.home, + ), + ]; +} +{{else if (eq flags.routerPackage "getx")}} +import 'package:{{flags.appSnake}}/src/imports/imports.dart'; +import 'package:{{flags.appSnake}}/src/routing/app_routes.dart'; + +{{#if (eq architecture "layer-first")}} +import 'package:{{flags.appSnake}}/src/presentation/screens/auth/login_screen.dart'; +import 'package:{{flags.appSnake}}/src/presentation/screens/auth/signup_screen.dart'; +import 'package:{{flags.appSnake}}/src/presentation/screens/auth/forgot_password_screen.dart'; +{{else if (eq architecture "mvc")}} +import 'package:{{flags.appSnake}}/src/views/auth/login_screen.dart'; +import 'package:{{flags.appSnake}}/src/views/auth/signup_screen.dart'; +import 'package:{{flags.appSnake}}/src/views/auth/forgot_password_screen.dart'; +{{else if (eq architecture "mvvm")}} +import 'package:{{flags.appSnake}}/src/ui/auth/login_screen.dart'; +import 'package:{{flags.appSnake}}/src/ui/auth/signup_screen.dart'; +import 'package:{{flags.appSnake}}/src/ui/auth/forgot_password_screen.dart'; +{{else}} +import 'package:{{flags.appSnake}}/src/features/auth/presentation/screens/login_screen.dart'; +import 'package:{{flags.appSnake}}/src/features/auth/presentation/screens/signup_screen.dart'; +import 'package:{{flags.appSnake}}/src/features/auth/presentation/screens/forgot_password_screen.dart'; +{{/if}} + +{{#if (eq architecture "layer-first")}} +import 'package:{{flags.appSnake}}/src/presentation/screens/home/home_page.dart'; +import 'package:{{flags.appSnake}}/src/presentation/screens/onboarding/onboarding_page.dart'; + +{{else if (eq architecture "mvc")}} +import 'package:{{flags.appSnake}}/src/views/home/home_page.dart'; +import 'package:{{flags.appSnake}}/src/views/onboarding/onboarding_page.dart'; + +{{else if (eq architecture "mvvm")}} +import 'package:{{flags.appSnake}}/src/ui/home/home_page.dart'; +import 'package:{{flags.appSnake}}/src/ui/onboarding/onboarding_page.dart'; + +{{else}} +import 'package:{{flags.appSnake}}/src/features/home/presentation/screens/home_page.dart'; +import 'package:{{flags.appSnake}}/src/features/onboarding/presentation/screens/onboarding_page.dart'; + +{{/if}} + +class AppRouter { + static List get getPages => [ + GetPage( + name: AppRoutes.onboarding, + page: () => const OnboardingPage(), + ), + GetPage( + name: AppRoutes.login, + page: () => const LoginScreen(), + ), + GetPage( + name: AppRoutes.signup, + page: () => const SignupScreen(), + ), + GetPage( + name: AppRoutes.forgotPassword, + page: () => const ForgotPasswordScreen(), + ), + GetPage( + name: AppRoutes.home, + page: () => const HomePage(), + ), + ]; +} +{{else}} +import 'package:flutter/material.dart'; +{{#if flags.isCupertino}} +import 'package:flutter/cupertino.dart'; +{{/if}} +import 'package:{{flags.appSnake}}/src/routing/app_routes.dart'; + +{{#if (eq architecture "layer-first")}} +import 'package:{{flags.appSnake}}/src/presentation/screens/auth/login_screen.dart'; +import 'package:{{flags.appSnake}}/src/presentation/screens/auth/signup_screen.dart'; +import 'package:{{flags.appSnake}}/src/presentation/screens/auth/forgot_password_screen.dart'; +{{else if (eq architecture "mvc")}} +import 'package:{{flags.appSnake}}/src/views/auth/login_screen.dart'; +import 'package:{{flags.appSnake}}/src/views/auth/signup_screen.dart'; +import 'package:{{flags.appSnake}}/src/views/auth/forgot_password_screen.dart'; +{{else if (eq architecture "mvvm")}} +import 'package:{{flags.appSnake}}/src/ui/auth/login_screen.dart'; +import 'package:{{flags.appSnake}}/src/ui/auth/signup_screen.dart'; +import 'package:{{flags.appSnake}}/src/ui/auth/forgot_password_screen.dart'; +{{else}} +import 'package:{{flags.appSnake}}/src/features/auth/presentation/screens/login_screen.dart'; +import 'package:{{flags.appSnake}}/src/features/auth/presentation/screens/signup_screen.dart'; +import 'package:{{flags.appSnake}}/src/features/auth/presentation/screens/forgot_password_screen.dart'; +{{/if}} + +{{#if (eq architecture "layer-first")}} +import 'package:{{flags.appSnake}}/src/presentation/screens/home/home_page.dart'; +import 'package:{{flags.appSnake}}/src/presentation/screens/onboarding/onboarding_page.dart'; + +{{else if (eq architecture "mvc")}} +import 'package:{{flags.appSnake}}/src/views/home/home_page.dart'; +import 'package:{{flags.appSnake}}/src/views/onboarding/onboarding_page.dart'; + +{{else if (eq architecture "mvvm")}} +import 'package:{{flags.appSnake}}/src/ui/home/home_page.dart'; +import 'package:{{flags.appSnake}}/src/ui/onboarding/onboarding_page.dart'; + +{{else}} +import 'package:{{flags.appSnake}}/src/features/home/presentation/screens/home_page.dart'; +import 'package:{{flags.appSnake}}/src/features/onboarding/presentation/screens/onboarding_page.dart'; + +{{/if}} + +class AppRouter { + static Route onGenerateRoute(RouteSettings settings) { + switch (settings.name) { + case AppRoutes.onboarding: + return {{#if flags.isCupertino}}CupertinoPageRoute{{else}}MaterialPageRoute{{/if}}( + builder: (_) => const OnboardingPage(), + settings: settings, + ); + case AppRoutes.login: + return {{#if flags.isCupertino}}CupertinoPageRoute{{else}}MaterialPageRoute{{/if}}( + builder: (_) => const LoginScreen(), + settings: settings, + ); + case AppRoutes.signup: + return {{#if flags.isCupertino}}CupertinoPageRoute{{else}}MaterialPageRoute{{/if}}( + builder: (_) => const SignupScreen(), + settings: settings, + ); + case AppRoutes.forgotPassword: + return {{#if flags.isCupertino}}CupertinoPageRoute{{else}}MaterialPageRoute{{/if}}( + builder: (_) => const ForgotPasswordScreen(), + settings: settings, + ); + case AppRoutes.home: + return {{#if flags.isCupertino}}CupertinoPageRoute{{else}}MaterialPageRoute{{/if}}( + builder: (_) => const HomePage(), + settings: settings, + ); + default: + return {{#if flags.isCupertino}}CupertinoPageRoute{{else}}MaterialPageRoute{{/if}}( + builder: (_) => const HomePage(), + settings: settings, + ); + } + } +} +{{/if}} \ No newline at end of file diff --git a/cli/templates/base/lib/src/routing/app_routes.dart.hbs b/cli/templates/base/lib/src/routing/app_routes.dart.hbs new file mode 100644 index 0000000..7128f84 --- /dev/null +++ b/cli/templates/base/lib/src/routing/app_routes.dart.hbs @@ -0,0 +1,46 @@ +{{#if (eq flags.routerPackage "go_router")}} +/// Centralized route path constants for GoRouter. +/// +/// Use these variables instead of raw strings throughout the app. +/// Example: `context.go(AppRoutes.onboarding)` instead of `context.go('/')`. +abstract final class AppRoutes { + AppRoutes._(); + + static const String splash = '/splash'; + static const String home = '/'; + static const String onboarding = '/onboarding'; + static const String login = '/login'; + static const String signup = '/signup'; + static const String forgotPassword = '/forgot-password'; +} +{{else if (eq flags.routerPackage "auto_route")}} +/// Centralized route name constants for AutoRoute. +/// +/// Reference these constants when navigating or defining routes +/// to avoid typos in route names. +abstract final class AppRoutes { + AppRoutes._(); + + static const String splash = '/splash'; + static const String home = '/'; + static const String onboarding = '/onboarding'; + static const String login = '/login'; + static const String signup = '/signup'; + static const String forgotPassword = '/forgot-password'; +} +{{else}} +/// Centralized route name constants for named Navigator routes. +/// +/// Use these constants with `Navigator.pushNamed(context, AppRoutes.onboarding)` +/// instead of inline strings to prevent typos and ease refactoring. +abstract final class AppRoutes { + AppRoutes._(); + + static const String splash = '/splash'; + static const String home = '/'; + static const String onboarding = '/onboarding'; + static const String login = '/login'; + static const String signup = '/signup'; + static const String forgotPassword = '/forgot-password'; +} +{{/if}} diff --git a/cli/templates/base/lib/src/routing/global_navigator.dart.hbs b/cli/templates/base/lib/src/routing/global_navigator.dart.hbs new file mode 100644 index 0000000..a7eb0a7 --- /dev/null +++ b/cli/templates/base/lib/src/routing/global_navigator.dart.hbs @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; + +/// Global [NavigatorState] key used by the app's root [Navigator]. +/// +/// This is wired into `MaterialApp.navigatorKey` in `app.dart` so that +/// navigation and overlays (e.g. toasts) can be triggered without a +/// local [BuildContext] reference. +final GlobalKey rootNavigatorKey = GlobalKey(); + +/// Convenient accessor for the current root [BuildContext]. +/// +/// Returns `null` before the app is mounted. Check for `null` before +/// using this in background services or repositories. +BuildContext? get rootContext => rootNavigatorKey.currentContext; + diff --git a/cli/templates/base/lib/src/services/auth_service.dart.hbs b/cli/templates/base/lib/src/services/auth_service.dart.hbs new file mode 100644 index 0000000..6e19da3 --- /dev/null +++ b/cli/templates/base/lib/src/services/auth_service.dart.hbs @@ -0,0 +1,78 @@ +import 'dart:async'; +import '../utils/utils.dart'; + +class AuthService { + AuthService._(); + static final AuthService instance = AuthService._(); + + // In-memory session for mock backend + Map? _currentUser; + final StreamController?> _authStateController = + StreamController?>.broadcast(); + + /// Stream of auth state changes. Emits the current user map or null. + Stream?> get authStateChanges => _authStateController.stream; + + FutureEither?> login({ + required String email, + required String password, + }) async { + return runTask(() async { + await Future.delayed(const Duration(seconds: 2)); + + // Mock validation + if (email.contains('error')) { + throw Exception('Mock authentication error'); + } + + _currentUser = { + 'id': 'mock-id-123', + 'email': email, + 'name': 'Mock User', + }; + _authStateController.add(_currentUser); + return _currentUser; + }, requiresNetwork: true); + } + + FutureEither?> signUp({ + required String name, + required String email, + required String password, + }) async { + return runTask(() async { + await Future.delayed(const Duration(seconds: 2)); + _currentUser = { + 'id': 'mock-id-${DateTime.now().millisecondsSinceEpoch}', + 'email': email, + 'name': name, + }; + _authStateController.add(_currentUser); + return _currentUser; + }, requiresNetwork: true); + } + + FutureEither forgotPassword({required String email}) async { + return runTask(() async { + await Future.delayed(const Duration(seconds: 1)); + }, requiresNetwork: true); + } + + FutureEither logout() async { + return runTask(() async { + await Future.delayed(const Duration(milliseconds: 500)); + _currentUser = null; + _authStateController.add(null); + }); + } + + FutureEither?> getCurrentUser() async { + return runTask(() async { + return _currentUser; + }); + } + + void dispose() { + _authStateController.close(); + } +} diff --git a/cli/templates/base/lib/src/services/copy_service.dart.hbs b/cli/templates/base/lib/src/services/copy_service.dart.hbs new file mode 100644 index 0000000..c556650 --- /dev/null +++ b/cli/templates/base/lib/src/services/copy_service.dart.hbs @@ -0,0 +1,16 @@ +import 'package:flutter/services.dart'; +import '../utils/utils.dart'; + +/// A service to handle clipboard operations. +class CopyService { + CopyService._(); + static final CopyService instance = CopyService._(); + + /// Copy text to the system clipboard. + FutureEither copy(String text) async { + return runTask(() async { + await Clipboard.setData(ClipboardData(text: text)); + AppLogger.info('Text copied to clipboard'); + }); + } +} diff --git a/cli/templates/base/lib/src/services/internet_connection_service.dart.hbs b/cli/templates/base/lib/src/services/internet_connection_service.dart.hbs new file mode 100644 index 0000000..fe336be --- /dev/null +++ b/cli/templates/base/lib/src/services/internet_connection_service.dart.hbs @@ -0,0 +1,10 @@ +import '../imports/imports.dart'; + +class InternetConnectionService { + InternetConnectionService(); + + final InternetConnection internetConnection = InternetConnection(); + + Future hasConnection() async => + await internetConnection.hasInternetAccess; +} diff --git a/cli/templates/base/lib/src/services/services.dart.hbs b/cli/templates/base/lib/src/services/services.dart.hbs new file mode 100644 index 0000000..a3d47f4 --- /dev/null +++ b/cli/templates/base/lib/src/services/services.dart.hbs @@ -0,0 +1,45 @@ +export 'auth_service.dart'; +export 'internet_connection_service.dart'; +{{#if flags.usesDio}} +export 'dio_service.dart'; +{{/if}} +{{#if (and flags.usesHttp (not flags.usesDio))}} +export 'http_service.dart'; +{{/if}} +{{#if flags.usesHive}} +export 'hive_service.dart'; +{{/if}} +{{#if flags.usesSharedPreferences}} +export 'storage_service.dart'; +{{/if}} +{{#if flags.usesSecureStorage}} +export 'secure_storage_service.dart'; +{{/if}} +{{#if flags.usesPathProvider}} +export 'path_service.dart'; +{{/if}} +{{#if flags.usesGeolocator}} +export 'location_service.dart'; +{{/if}} +{{#unless flags.usesFlutterHooks}} +export 'copy_service.dart'; +{{#if flags.usesSharePlus}} +export 'share_service.dart'; +{{/if}} +{{#if flags.usesPermissionHandler}} +export 'permission_service.dart'; +{{/if}} +{{#if flags.usesUrlLauncher}} +export 'url_launcher_service.dart'; +{{/if}} +{{#if (or flags.usesImagePicker flags.usesFilePicker)}} +export 'media_service.dart'; +{{/if}} +{{#if flags.usesDeviceInfoPlus}} +export 'device_info_service.dart'; +{{/if}} +{{#if flags.usesAppVersionUpdate}} +export 'version_update_service.dart'; +{{/if}} +{{/unless}} + diff --git a/cli/templates/base/lib/src/shared/app_assets.dart.hbs b/cli/templates/base/lib/src/shared/app_assets.dart.hbs new file mode 100644 index 0000000..f36f032 --- /dev/null +++ b/cli/templates/base/lib/src/shared/app_assets.dart.hbs @@ -0,0 +1,15 @@ +class AppAssets { + AppAssets._(); + + static const String _basePath = 'assets'; + static const String _iconsPath = '$_basePath/icons'; + + // SVGs + static const String googleIcon = '$_iconsPath/google.svg'; + static const String facebookIcon = '$_iconsPath/facebook.svg'; + static const String appleIcon = '$_iconsPath/apple.svg'; + + // You can add more categories here as well, such as: + // static const String _imagesPath = '$_basePath/images'; + // static const String logo = '$_imagesPath/logo.png'; +} diff --git a/cli/templates/base/lib/src/shared/enums/app_status.dart.hbs b/cli/templates/base/lib/src/shared/enums/app_status.dart.hbs new file mode 100644 index 0000000..c08bf39 --- /dev/null +++ b/cli/templates/base/lib/src/shared/enums/app_status.dart.hbs @@ -0,0 +1,40 @@ +/// Represents the async lifecycle state of any data/operation. +/// +/// Use this in state classes across all state management patterns (Riverpod, +/// Bloc, Provider, etc.) to replace ad-hoc boolean flags with a clean enum. +/// +/// Usage: +/// ```dart +/// // In your state class +/// AppStatus status = AppStatus.initial; +/// +/// // In your UI +/// switch (status) { +/// AppStatus.initial => const SizedBox.shrink(), +/// AppStatus.loading => const AppLoading(), +/// AppStatus.success => YourContentWidget(), +/// AppStatus.failure => AppErrorWidget(onRetry: _load), +/// } +/// ``` +enum AppStatus { + /// No operation started yet — initial empty state. + initial, + + /// Async operation in progress. + loading, + + /// Operation completed successfully. + success, + + /// Operation failed. + failure, +} + +/// Extension helpers for [AppStatus]. +extension AppStatusX on AppStatus { + bool get isInitial => this == AppStatus.initial; + bool get isLoading => this == AppStatus.loading; + bool get isSuccess => this == AppStatus.success; + bool get isFailure => this == AppStatus.failure; + bool get isDone => isSuccess || isFailure; +} diff --git a/cli/templates/base/lib/src/shared/enums/button_enums.dart.hbs b/cli/templates/base/lib/src/shared/enums/button_enums.dart.hbs new file mode 100644 index 0000000..cc156fc --- /dev/null +++ b/cli/templates/base/lib/src/shared/enums/button_enums.dart.hbs @@ -0,0 +1,22 @@ +enum ButtonVariant { + primary, + secondary, + outline, + ghost, + danger, + success, +} + +enum ButtonSize { + small, + medium, + large, +} + +enum ButtonState { + idle, + loading, + disabled, + success, + error, +} diff --git a/cli/templates/base/lib/src/shared/enums/enums.dart.hbs b/cli/templates/base/lib/src/shared/enums/enums.dart.hbs new file mode 100644 index 0000000..134e43b --- /dev/null +++ b/cli/templates/base/lib/src/shared/enums/enums.dart.hbs @@ -0,0 +1,3 @@ +export 'button_enums.dart'; +export 'app_status.dart'; +export 'snack_bar_type.dart'; diff --git a/cli/templates/base/lib/src/shared/enums/snack_bar_type.dart.hbs b/cli/templates/base/lib/src/shared/enums/snack_bar_type.dart.hbs new file mode 100644 index 0000000..ed390af --- /dev/null +++ b/cli/templates/base/lib/src/shared/enums/snack_bar_type.dart.hbs @@ -0,0 +1,22 @@ +/// Type of snackbar to show — drives colour and icon selection. +/// +/// Used by `context.showTypedSnackBar()` in `context_extension.dart`. +/// +/// Usage: +/// ```dart +/// context.showTypedSnackBar('Saved!', type: SnackBarType.success); +/// context.showTypedSnackBar('No internet', type: SnackBarType.warning); +/// ``` +enum SnackBarType { + /// Neutral informational message. + info, + + /// Operation succeeded. + success, + + /// Non-blocking warning the user should notice. + warning, + + /// Something went wrong. + error, +} diff --git a/cli/templates/base/lib/src/shared/helpers/format_number.dart.hbs b/cli/templates/base/lib/src/shared/helpers/format_number.dart.hbs new file mode 100644 index 0000000..016f0c7 --- /dev/null +++ b/cli/templates/base/lib/src/shared/helpers/format_number.dart.hbs @@ -0,0 +1,18 @@ +/// Formats a number with comma separators for thousands (e.g. 1,000,000). +String formatNumber(num number) { + final parts = number.toString().split('.'); + final whole = parts[0]; + final buffer = StringBuffer(); + int count = 0; + + for (int i = whole.length - 1; i >= 0; i--) { + buffer.write(whole[i]); + count++; + if (count % 3 == 0 && i != 0) { + buffer.write(','); + } + } + + final formattedWhole = buffer.toString().split('').reversed.join(); + return parts.length > 1 ? '$formattedWhole.${parts[1]}' : formattedWhole; +} diff --git a/cli/templates/base/lib/src/shared/helpers/imports.dart.hbs b/cli/templates/base/lib/src/shared/helpers/imports.dart.hbs new file mode 100644 index 0000000..632efeb --- /dev/null +++ b/cli/templates/base/lib/src/shared/helpers/imports.dart.hbs @@ -0,0 +1,3 @@ +export 'show_toast.dart'; +export 'show_dialog.dart'; +export 'show_app_sheet.dart'; \ No newline at end of file diff --git a/cli/templates/base/lib/src/shared/helpers/show_app_sheet.dart.hbs b/cli/templates/base/lib/src/shared/helpers/show_app_sheet.dart.hbs new file mode 100644 index 0000000..ee8d6a6 --- /dev/null +++ b/cli/templates/base/lib/src/shared/helpers/show_app_sheet.dart.hbs @@ -0,0 +1,42 @@ +import 'dart:ui'; +import '../../imports/imports.dart'; + +/// Shows a highly customizable bottom sheet with premium features like backdrop blur. +/// +/// This helper uses the [rootNavigatorKey] to display the sheet +/// without needing a local [BuildContext]. +Future showAppSheet({ + required Widget child, + bool hasBlur = true, + bool enableDrag = true, + bool isScrollControlled = true, + bool useSafeArea = true, +}) { + final context = rootContext; + if (context == null) return Future.value(null); + + return showModalBottomSheet( + context: context, + isScrollControlled: isScrollControlled, + backgroundColor: Colors.transparent, + barrierColor: context.theme.colorScheme.scrim.withValues(alpha: 0.2), + elevation: 0, + useSafeArea: useSafeArea, + enableDrag: enableDrag, + shape: const RoundedRectangleBorder( + borderRadius: AppBorders.bottomSheet, + ), + builder: (context) => GestureDetector( + behavior: HitTestBehavior.opaque, + child: BackdropFilter( + filter: ImageFilter.blur( + sigmaX: hasBlur ? 3 : 0, + sigmaY: hasBlur ? 3 : 0, + ), + child: SizedBox( + child: child, + ), + ), + ), + ); +} diff --git a/cli/templates/base/lib/src/shared/helpers/show_dialog.dart.hbs b/cli/templates/base/lib/src/shared/helpers/show_dialog.dart.hbs new file mode 100644 index 0000000..8043f27 --- /dev/null +++ b/cli/templates/base/lib/src/shared/helpers/show_dialog.dart.hbs @@ -0,0 +1,49 @@ +import 'dart:ui'; +import '../../imports/imports.dart'; + +/// Shows a premium custom dialog with optional backdrop blur. +/// +/// This helper uses the [rootNavigatorKey] to display the dialog +/// without needing a local [BuildContext]. +Future showAppDialog({ + required Widget child, + bool hasBlur = true, + double blurSigma = 5.0, + Color barrierColor = Colors.black26, + bool dismissible = true, + Duration duration = const Duration(milliseconds: 300), +}) { + final context = rootContext; + if (context == null) return Future.value(null); + + return showGeneralDialog( + context: context, + barrierColor: barrierColor, + barrierDismissible: dismissible, + barrierLabel: 'AppDialog', + transitionDuration: duration, + pageBuilder: (context, animation, secondaryAnimation) => child, + transitionBuilder: (context, animation, secondaryAnimation, child) { + final curve = Curves.easeInOut.transform(animation.value); + return BackdropFilter( + filter: ImageFilter.blur( + sigmaX: hasBlur ? (blurSigma * animation.value) : 0, + sigmaY: hasBlur ? (blurSigma * animation.value) : 0, + ), + child: Opacity( + opacity: animation.value, + child: Transform.scale( + scale: 0.9 + (0.1 * curve), + child: child, + ), + ), + ); + }, + ); +} + +/// Alias for [showAppDialog] to maintain compatibility with custom references. +Future showCustomDialogue({ + required Widget child, + bool hasBlur = true, +}) => showAppDialog(child: child, hasBlur: hasBlur); diff --git a/cli/templates/base/lib/src/shared/helpers/show_toast.dart.hbs b/cli/templates/base/lib/src/shared/helpers/show_toast.dart.hbs new file mode 100644 index 0000000..ce42ec1 --- /dev/null +++ b/cli/templates/base/lib/src/shared/helpers/show_toast.dart.hbs @@ -0,0 +1,92 @@ +import '../../imports/imports.dart'; + +void showToast( + BuildContext context, { + required String message, + String? status = 'success', + dynamic icon, + Duration? duration, + bool? autoDismiss, +}) { + final toastStatus = status ?? 'info'; + final colorScheme = context.colors; + final appColors = context.appColors; + + final (backgroundColor, foregroundColor, iconColor) = switch (toastStatus) { + 'error' => ( + colorScheme.errorContainer, + colorScheme.onErrorContainer, + colorScheme.error, + ), + 'success' => ( + appColors.successContainer ?? appColors.success, + appColors.onSuccessContainer ?? appColors.onSuccess, + appColors.success, + ), + 'warning' => ( + appColors.warningContainer ?? appColors.warning, + appColors.onWarningContainer ?? appColors.onWarning, + appColors.warning, + ), + 'info' => ( + appColors.infoContainer ?? appColors.info, + appColors.onInfoContainer ?? appColors.onInfo, + appColors.info, + ), + _ => ( + context.theme.scaffoldBackgroundColor, + colorScheme.onSurface, + colorScheme.onSurfaceVariant, + ), + }; + + return ToastBar( + position: ToastPosition.top, + autoDismiss: autoDismiss ?? true, + toastDuration: duration ?? const Duration(seconds: 2), + animationDuration: const Duration(milliseconds: 150), + animationCurve: Curves.easeIn, + builder: (context) => ToastCard( + color: backgroundColor, + shadowColor: colorScheme.shadow.withValues(alpha: 0.05), + leading: AppIcon( + icon: icon ?? + (toastStatus == 'success' + ? {{#if flags.usesIconsaxPlus}}IconsaxPlusBold.tick_circle{{else if flags.usesFlutterRemix}}FlutterRemix.checkbox_circle_fill{{else if flags.usesHugeicons}}HugeIcons.strokeRoundedTickCircle{{else}}Icons.check_circle_outline{{/if}} + : toastStatus == 'error' + ? {{#if flags.usesIconsaxPlus}}IconsaxPlusBold.danger{{else if flags.usesFlutterRemix}}FlutterRemix.error_warning_fill{{else if flags.usesHugeicons}}HugeIcons.strokeRoundedAlertCircle{{else}}Icons.error_outline{{/if}} + : {{#if flags.usesIconsaxPlus}}IconsaxPlusBold.info_circle{{else if flags.usesFlutterRemix}}FlutterRemix.information_fill{{else if flags.usesHugeicons}}HugeIcons.strokeRoundedInformationCircle{{else}}Icons.info_outline{{/if}}), + color: iconColor, + size: {{res 22 'sp' flags.usesScreenutil}}, + ), + title: Text( + message, + style: context.theme.textTheme.labelSmall!.copyWith( + fontWeight: FontWeight.w600, + fontSize: {{res 11 'sp' flags.usesScreenutil}}, + color: foregroundColor, + ), + ), + ), + ).show(context); +} + +void showGlobalToast({ + required String message, + String? status = 'success', + dynamic icon, + Duration? duration, + bool? autoDismiss, +}) { + final ctx = rootContext; + if (ctx == null) return; + + showToast( + ctx, + message: message, + status: status, + icon: icon, + duration: duration, + autoDismiss: autoDismiss, + ); +} diff --git a/cli/templates/base/lib/src/shared/hooks/hooks.dart.hbs b/cli/templates/base/lib/src/shared/hooks/hooks.dart.hbs new file mode 100644 index 0000000..51d92ff --- /dev/null +++ b/cli/templates/base/lib/src/shared/hooks/hooks.dart.hbs @@ -0,0 +1,13 @@ +{{#if flags.usesFlutterHooks}} +export 'use_copy.dart'; +{{#if flags.usesSharePlus}} +export 'use_share.dart'; +{{/if}} +{{#if flags.usesPermissionHandler}} +export 'use_permission.dart'; +{{/if}} +{{#if flags.usesUrlLauncher}} +export 'use_launch_url.dart'; +{{/if}} +export 'use_timer.dart'; +{{/if}} diff --git a/cli/templates/base/lib/src/shared/hooks/use_copy.dart.hbs b/cli/templates/base/lib/src/shared/hooks/use_copy.dart.hbs new file mode 100644 index 0000000..203b9d1 --- /dev/null +++ b/cli/templates/base/lib/src/shared/hooks/use_copy.dart.hbs @@ -0,0 +1,41 @@ +{{#if flags.usesFlutterHooks}} +import '../../imports/imports.dart'; + +/// A hook to handle copying text to clipboard with state feedback. +(Future Function(String), bool) useCopy() { + final hasCopied = useState(false); + + Future copyToClipboard(String text) async { + try { + hasCopied.value = true; + await Clipboard.setData(ClipboardData(text: text)); + + showGlobalToast( + message: 'Copied successfully', + status: 'success', + ); + + await Future.delayed( + const Duration(seconds: 2), + () => hasCopied.value = false, + ); + } catch (e) { + AppLogger.error('Error copying to clipboard: $e'); + hasCopied.value = false; + + showGlobalToast( + message: 'Failed to copy', + status: 'error', + ); + } + } + + useEffect(() { + return () { + hasCopied.value = false; + }; + }, []); + + return (copyToClipboard, hasCopied.value); +} +{{/if}} diff --git a/cli/templates/base/lib/src/shared/hooks/use_timer.dart.hbs b/cli/templates/base/lib/src/shared/hooks/use_timer.dart.hbs new file mode 100644 index 0000000..92a2af3 --- /dev/null +++ b/cli/templates/base/lib/src/shared/hooks/use_timer.dart.hbs @@ -0,0 +1,158 @@ +{{#if flags.usesFlutterHooks}} +import 'dart:async'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +/// A lightweight immutable value representing the current time and time left. +class TimerTick { + const TimerTick({required this.now, required this.remaining}); + + final DateTime now; + final Duration remaining; + + /// Convenience formatted remaining time as mm:ss. + String get remainingMmSs => formatMmSs(remaining); +} + +/// Runs a countdown for [duration], invokes [onComplete] at the end, and +/// returns the current [TimerTick] containing `now` and `remaining`. +/// +/// - Set [isActive] to false to stop the timer. +/// - Changing [duration], [isActive], [tick], or [onComplete] restarts logic. +/// - The timer is cancelled automatically on dispose. +TimerTick useTimer({ + required Duration duration, + required VoidCallback onComplete, + bool isActive = true, + Duration tick = const Duration(seconds: 1), + bool autoStart = true, +}) => + use(_TimerHook( + duration: duration, + onComplete: onComplete, + isActive: isActive, + tick: tick, + autoStart: autoStart, + )); + +class _TimerHook extends Hook { + const _TimerHook({ + required this.duration, + required this.onComplete, + required this.isActive, + required this.tick, + required this.autoStart, + }); + + final Duration duration; + final VoidCallback onComplete; + final bool isActive; + final Duration tick; + final bool autoStart; + + @override + HookState createState() => _TimerHookState(); +} + +class _TimerHookState extends HookState { + Timer? _timer; + late DateTime _now; + late Duration _remaining; + DateTime? startedAt; + int? _lastLoggedRemainingSecs; + late VoidCallback _onComplete; + + @override + void initHook() { + super.initHook(); + _now = DateTime.now(); + _remaining = hook.duration; + _onComplete = hook.onComplete; + _lastLoggedRemainingSecs = _remaining.inSeconds; + + if (hook.autoStart) { + _startOrStop(); + } + } + + @override + void didUpdateHook(covariant _TimerHook oldHook) { + super.didUpdateHook(oldHook); + final shouldRestart = oldHook.duration != hook.duration || + oldHook.isActive != hook.isActive || + oldHook.tick != hook.tick; + // Always keep the latest completion callback without forcing a restart. + if (oldHook.onComplete != hook.onComplete) { + _onComplete = hook.onComplete; + } + if (shouldRestart) _startOrStop(); + } + + @override + TimerTick build(BuildContext context) => + TimerTick(now: _now, remaining: _remaining); + + void _startOrStop() { + _timer?.cancel(); + startedAt = DateTime.now(); + _now = startedAt!; + _remaining = hook.duration; + _lastLoggedRemainingSecs = _remaining.inSeconds; + if (!hook.isActive) { + return; + } + + void handleTick() { + final current = DateTime.now(); + final elapsed = current.difference(startedAt!); + final left = hook.duration - elapsed; + final leftSecs = left.inSeconds; + if (left <= Duration.zero) { + _now = current; + _remaining = Duration.zero; + _timer?.cancel(); + + if (context.mounted) _onComplete(); + setState(() {}); + return; + } + + setState(() { + _now = current; + _remaining = left; + }); + + if (_lastLoggedRemainingSecs != leftSecs) { + _lastLoggedRemainingSecs = leftSecs; + } + } + + // Fire an immediate tick so the UI reflects that the timer started + // without waiting for the first interval. + handleTick(); + + // Periodic updates to compute remaining and call completion. + _timer = Timer.periodic(hook.tick, (_) => handleTick()); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } +} + +/// Formats a [Duration] adaptively as: +/// - H:MM:SS when hours > 0 +/// - M:SS otherwise (including pure seconds) +String formatMmSs(Duration duration) { + final totalSeconds = duration.inSeconds < 0 ? 0 : duration.inSeconds; + final hours = totalSeconds ~/ 3600; + final minutes = (totalSeconds % 3600) ~/ 60; + final seconds = totalSeconds % 60; + if (hours > 0) { + return '$hours:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}'; + } + return '$minutes:${seconds.toString().padLeft(2, '0')}'; +} +{{/if}} diff --git a/cli/templates/base/lib/src/shared/shared.dart.hbs b/cli/templates/base/lib/src/shared/shared.dart.hbs new file mode 100644 index 0000000..eeab103 --- /dev/null +++ b/cli/templates/base/lib/src/shared/shared.dart.hbs @@ -0,0 +1,12 @@ +export 'helpers/imports.dart'; +export '../extensions/extensions.dart'; +export '../utils/utils.dart'; + +export 'enums/enums.dart'; +export 'widgets/widgets.dart'; +export '../theme/theme_constants.dart'; +export 'wrappers/wrappers.dart'; +export 'app_assets.dart'; +{{#if flags.usesFlutterHooks}} +export 'hooks/hooks.dart'; +{{/if}} diff --git a/cli/templates/base/lib/src/shared/widgets/app_button.dart.hbs b/cli/templates/base/lib/src/shared/widgets/app_button.dart.hbs new file mode 100644 index 0000000..d80c828 --- /dev/null +++ b/cli/templates/base/lib/src/shared/widgets/app_button.dart.hbs @@ -0,0 +1,144 @@ +import '../../imports/imports.dart'; + +/// A fully themed button supporting all [ButtonVariant]s and [ButtonSize]s. +/// +/// Usage: +/// ```dart +/// AppButton( +/// label: 'Save', +/// onPressed: _save, +/// variant: ButtonVariant.primary, +/// size: ButtonSize.large, +/// isLoading: state.isLoading, +/// ) +/// ``` +class AppButton extends StatelessWidget { + const AppButton({ + super.key, + required this.label, + this.onPressed, + this.variant = ButtonVariant.primary, + this.color, + this.textColor, + this.height = ButtonSize.medium, + this.width, + this.isLoading = false, + this.isFullWidth = false, + this.prefixIcon, + this.suffixIcon, + }); + + final String label; + final VoidCallback? onPressed; + final ButtonVariant variant; + final Color? color; + final Color? textColor; + final ButtonSize height; + final ButtonSize? width; + final bool isLoading; + final bool isFullWidth; + final Widget? prefixIcon; + final Widget? suffixIcon; + + @override + Widget build(BuildContext context) { + final cs = context.theme.colorScheme; + final appColors = context.theme.extension()!; + final isDisabled = onPressed == null || isLoading; + + final double buttonHeight = switch (height) { + ButtonSize.small => {{res 36 'h' flags.usesScreenutil}}, + ButtonSize.medium => {{res 48 'h' flags.usesScreenutil}}, + ButtonSize.large => {{res 56 'h' flags.usesScreenutil}}, + }; + + final double? buttonWidth = switch (width) { + ButtonSize.small => {{res 100 'w' flags.usesScreenutil}}, + ButtonSize.medium => {{res 150 'w' flags.usesScreenutil}}, + ButtonSize.large => {{res 200 'w' flags.usesScreenutil}}, + null => null, + }; + + final double horizontalPadding = switch (height) { + ButtonSize.small => {{res 12 'w' flags.usesScreenutil}}, + ButtonSize.medium => {{res 20 'w' flags.usesScreenutil}}, + ButtonSize.large => {{res 28 'w' flags.usesScreenutil}}, + }; + + final double fontSize = switch (height) { + ButtonSize.small => {{res 12 'sp' flags.usesScreenutil}}, + ButtonSize.medium => {{res 14 'sp' flags.usesScreenutil}}, + ButtonSize.large => {{res 16 'sp' flags.usesScreenutil}}, + }; + + final (bg, fg, border) = switch (variant) { + ButtonVariant.primary => (color ?? cs.primary, color ?? cs.onPrimary, null), + ButtonVariant.secondary => (cs.secondaryContainer, cs.onSecondaryContainer, null), + ButtonVariant.outline => (Colors.transparent, cs.primary, BorderSide(color: cs.outline, width: 1.5)), + ButtonVariant.ghost => (Colors.transparent, cs.primary, null), + ButtonVariant.danger => (cs.error, cs.onError, null), + ButtonVariant.success => (appColors.success, appColors.onSuccess, null), + }; + + final child = AnimatedSwitcher( + duration: AppDurations.fast, + switchInCurve: AppCurves.decelerate, + child: isLoading + ? SizedBox( + key: const ValueKey('loader'), + width: {{res 20 'w' flags.usesScreenutil}}, + height: {{res 20 'h' flags.usesScreenutil}}, + child: CircularProgressIndicator( + strokeWidth: 2, + color: fg, + ), + ) + : Row( + key: const ValueKey('content'), + mainAxisSize: MainAxisSize.min, + children: [ + if (prefixIcon != null) ...[ + prefixIcon!, + {{#if flags.usesScreenutil}}SizedBox(width: 8.w){{else}}const SizedBox(width: 8){{/if}}, + ], + Text( + label, + style: TextStyle( + fontSize: fontSize, + fontWeight: FontWeight.w600, + color: isDisabled ? fg.withValues(alpha: 0.5) : textColor ?? fg, + ), + ), + if (suffixIcon != null) ...[ + {{#if flags.usesScreenutil}}SizedBox(width: 8.w){{else}}const SizedBox(width: 8){{/if}}, + suffixIcon!, + ], + ], + ), + ); + + return AnimatedOpacity( + duration: AppDurations.fast, + opacity: isDisabled ? 0.6 : 1.0, + child: SizedBox( + width: isFullWidth ? double.infinity : buttonWidth, + height: buttonHeight, + child: TextButton( + onPressed: isDisabled ? null : onPressed, + style: TextButton.styleFrom( + backgroundColor: bg, + foregroundColor: fg, + padding: EdgeInsets.symmetric(horizontal: horizontalPadding), + shape: border != null + ? RoundedRectangleBorder( + borderRadius: AppBorders.button, + side: border, + ) + : const RoundedRectangleBorder(borderRadius: AppBorders.button), + ), + child: child, + ), + ), + ); + } +} diff --git a/cli/templates/base/lib/src/shared/widgets/app_card.dart.hbs b/cli/templates/base/lib/src/shared/widgets/app_card.dart.hbs new file mode 100644 index 0000000..390a271 --- /dev/null +++ b/cli/templates/base/lib/src/shared/widgets/app_card.dart.hbs @@ -0,0 +1,126 @@ +import '../../imports/imports.dart'; + +/// A themed card widget with consistent padding, radius, and optional header. +/// +/// Usage: +/// ```dart +/// AppCard( +/// child: Text('Card content'), +/// ) +/// +/// // With a header +/// AppCard( +/// title: 'Recent Transactions', +/// trailing: TextButton(onPressed: _seeAll, child: const Text('See all')), +/// child: TransactionList(), +/// ) +/// ``` +class AppCard extends StatelessWidget { + const AppCard({ + super.key, + required this.child, + this.title, + this.subtitle, + this.leading, + this.trailing, + this.padding, + this.margin, + this.onTap, + this.showShadow = false, + this.color, + }); + + final Widget child; + final String? title; + final String? subtitle; + final Widget? leading; + final Widget? trailing; + final EdgeInsetsGeometry? padding; + final EdgeInsetsGeometry? margin; + final VoidCallback? onTap; + + /// When true, uses [AppShadows.card] instead of a border outline. + final bool showShadow; + final Color? color; + + @override + Widget build(BuildContext context) { + final cs = context.theme.colorScheme; + final tt = context.theme.textTheme; + + final cardColor = color ?? cs.surfaceContainerLow; + + final Widget content = Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (title != null || leading != null || trailing != null) + Padding( + padding: EdgeInsets.only( + left: AppSpacing.md, + right: AppSpacing.md, + top: AppSpacing.md, + bottom: AppSpacing.sm, + ), + child: Row( + children: [ + if (leading != null) ...[leading!, {{#if flags.usesScreenutil}}SizedBox(width: 12.w){{else}}const SizedBox(width: 12){{/if}}], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (title != null) + Text( + title!, + style: tt.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + if (subtitle != null) + Text( + subtitle!, + style: tt.bodySmall?.copyWith( + color: cs.onSurfaceVariant, + ), + ), + ], + ), + ), + if (trailing != null) trailing!, + ], + ), + ), + Padding( + padding: padding ?? + EdgeInsets.fromLTRB( + AppSpacing.md, + title == null ? AppSpacing.md : 0, + AppSpacing.md, + AppSpacing.md, + ), + child: child, + ), + ], + ); + + return Container( + margin: margin, + decoration: BoxDecoration( + color: cardColor, + borderRadius: AppBorders.card, + border: showShadow + ? null + : Border.all(color: cs.outlineVariant, width: 1), + boxShadow: showShadow ? AppShadows.card : AppShadows.none, + ), + clipBehavior: Clip.antiAlias, + child: onTap != null + ? InkWell( + onTap: onTap, + borderRadius: AppBorders.card, + child: content, + ) + : content, + ); + } +} diff --git a/cli/templates/base/lib/src/shared/widgets/app_divider.dart.hbs b/cli/templates/base/lib/src/shared/widgets/app_divider.dart.hbs new file mode 100644 index 0000000..d4a2f0e --- /dev/null +++ b/cli/templates/base/lib/src/shared/widgets/app_divider.dart.hbs @@ -0,0 +1,88 @@ +import '../../imports/imports.dart'; + +/// A themed horizontal divider using [ColorScheme.outlineVariant]. +/// +/// Prefer this over the raw [Divider] widget to stay consistent with the +/// app's colour scheme without passing explicit colours everywhere. +/// +/// Usage: +/// ```dart +/// const AppDivider() // standard 1-pt divider +/// const AppDivider.thick() // 2-pt emphasis divider +/// AppDivider(indent: 16) // inset divider (like list separator) +/// ``` +class AppDivider extends StatelessWidget { + const AppDivider({ + super.key, + this.height = 1, + this.thickness = 1, + this.indent = 0, + this.endIndent = 0, + this.color, + }); + + /// 2-pt visually prominent divider — use between major content sections. + const AppDivider.thick({ + super.key, + this.height = 2, + this.thickness = 2, + this.indent = 0, + this.endIndent = 0, + this.color, + }); + + final double height; + final double thickness; + final double indent; + final double endIndent; + final Color? color; + + @override + Widget build(BuildContext context) { + return Divider( + height: height, + thickness: thickness, + indent: indent, + endIndent: endIndent, + color: color ?? context.theme.colorScheme.outlineVariant, + ); + } +} + +/// A themed vertical divider using [ColorScheme.outlineVariant]. +/// +/// Usage: +/// ```dart +/// Row(children: [ +/// Text('Left'), +/// const AppVerticalDivider(), +/// Text('Right'), +/// ]) +/// ``` +class AppVerticalDivider extends StatelessWidget { + const AppVerticalDivider({ + super.key, + this.width = 1, + this.thickness = 1, + this.indent = 0, + this.endIndent = 0, + this.color, + }); + + final double width; + final double thickness; + final double indent; + final double endIndent; + final Color? color; + + @override + Widget build(BuildContext context) { + return VerticalDivider( + width: width, + thickness: thickness, + indent: indent, + endIndent: endIndent, + color: color ?? context.theme.colorScheme.outlineVariant, + ); + } +} diff --git a/cli/templates/base/lib/src/shared/widgets/app_empty_state.dart.hbs b/cli/templates/base/lib/src/shared/widgets/app_empty_state.dart.hbs new file mode 100644 index 0000000..e3cf80b --- /dev/null +++ b/cli/templates/base/lib/src/shared/widgets/app_empty_state.dart.hbs @@ -0,0 +1,73 @@ +import '../../imports/imports.dart'; + +/// Displays an empty state with an icon, title, optional subtitle, and action. +/// +/// Usage: +/// ```dart +/// AppEmptyState( +/// icon: Icons.inbox_outlined, +/// title: 'No messages yet', +/// subtitle: 'Your inbox will appear here.', +/// actionLabel: 'Refresh', +/// onAction: _refresh, +/// ) +/// ``` +class AppEmptyState extends StatelessWidget { + const AppEmptyState({ + super.key, + this.icon = {{#if flags.usesIconsaxPlus}}IconsaxPlusLinear.box{{else if flags.usesFlutterRemix}}FlutterRemix.inbox_line{{else if flags.usesHugeicons}}HugeIcons.strokeRoundedInbox{{else}}Icons.inbox_outlined{{/if}}, + required this.title, + this.subtitle, + this.actionLabel, + this.onAction, + }); + + final dynamic icon; + final String title; + final String? subtitle; + final String? actionLabel; + final VoidCallback? onAction; + + @override + Widget build(BuildContext context) { + final cs = context.theme.colorScheme; + final tt = context.theme.textTheme; + + return Center( + child: Padding( + padding: {{#if flags.usesScreenutil}}EdgeInsets.all(40.w){{else}}const EdgeInsets.all(40){{/if}}, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AppIcon(icon: icon, size: {{res 64 'sp' flags.usesScreenutil}}, color: cs.onSurfaceVariant.withValues(alpha: 0.5)), + {{#if flags.usesScreenutil}}SizedBox(height: 20.h){{else}}const SizedBox(height: 20){{/if}}, + Text( + title, + style: tt.titleMedium?.copyWith( + color: cs.onSurface, + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + ), + if (subtitle != null) ...[ + {{#if flags.usesScreenutil}}SizedBox(height: 8.h){{else}}const SizedBox(height: 8){{/if}}, + Text( + subtitle!, + style: tt.bodyMedium?.copyWith(color: cs.onSurfaceVariant), + textAlign: TextAlign.center, + ), + ], + if (actionLabel != null && onAction != null) ...[ + {{#if flags.usesScreenutil}}SizedBox(height: 28.h){{else}}const SizedBox(height: 28){{/if}}, + AppButton( + label: actionLabel!, + onPressed: onAction, + variant: ButtonVariant.secondary, + ), + ], + ], + ), + ), + ); + } +} diff --git a/cli/templates/base/lib/src/shared/widgets/app_error_widget.dart.hbs b/cli/templates/base/lib/src/shared/widgets/app_error_widget.dart.hbs new file mode 100644 index 0000000..6a2b0a6 --- /dev/null +++ b/cli/templates/base/lib/src/shared/widgets/app_error_widget.dart.hbs @@ -0,0 +1,69 @@ +import '../../imports/imports.dart'; + +/// Displays an error state with an icon, title, optional body, and retry button. +/// +/// Usage: +/// ```dart +/// AppErrorWidget( +/// title: 'Something went wrong', +/// message: error.toString(), +/// onRetry: () => ref.invalidate(myProvider), +/// ) +/// ``` +class AppErrorWidget extends StatelessWidget { + const AppErrorWidget({ + super.key, + this.title = 'Something went wrong', + this.message, + this.onRetry, + this.icon = {{#if flags.usesIconsaxPlus}}IconsaxPlusLinear.danger{{else if flags.usesFlutterRemix}}FlutterRemix.error_warning_line{{else if flags.usesHugeicons}}HugeIcons.strokeRoundedAlertCircle{{else}}Icons.error_outline_rounded{{/if}}, + }); + + final String title; + final String? message; + final VoidCallback? onRetry; + final dynamic icon; + + @override + Widget build(BuildContext context) { + final cs = context.theme.colorScheme; + final tt = context.theme.textTheme; + + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AppIcon(icon: icon, size: 56, color: cs.error), + const SizedBox(height: 16), + Text( + title, + style: tt.titleMedium?.copyWith( + color: cs.onSurface, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + if (message != null) ...[ + const SizedBox(height: 8), + Text( + message!, + style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant), + textAlign: TextAlign.center, + ), + ], + if (onRetry != null) ...[ + const SizedBox(height: 24), + AppButton( + label: 'Try Again', + onPressed: onRetry, + variant: ButtonVariant.outline, + ), + ], + ], + ), + ), + ); + } +} diff --git a/cli/templates/base/lib/src/shared/widgets/app_icon.dart.hbs b/cli/templates/base/lib/src/shared/widgets/app_icon.dart.hbs new file mode 100644 index 0000000..8ba5ae4 --- /dev/null +++ b/cli/templates/base/lib/src/shared/widgets/app_icon.dart.hbs @@ -0,0 +1,25 @@ +import '../../imports/imports.dart'; + +/// A wrapper widget that handles different icon libraries. +class AppIcon extends StatelessWidget { + const AppIcon({ + super.key, + required this.icon, + this.size, + this.color, + }); + + /// The icon to display. + final IconData icon; + final double? size; + final Color? color; + + @override + Widget build(BuildContext context) { + return Icon( + icon, + size: size, + color: color, + ); + } +} diff --git a/cli/templates/base/lib/src/shared/widgets/app_loading.dart.hbs b/cli/templates/base/lib/src/shared/widgets/app_loading.dart.hbs new file mode 100644 index 0000000..b6443ef --- /dev/null +++ b/cli/templates/base/lib/src/shared/widgets/app_loading.dart.hbs @@ -0,0 +1,54 @@ +import '../../imports/imports.dart'; + +/// Centered loading indicator using the primary colour from the theme. +/// +/// Usage: +/// ```dart +/// // Simple inline loader +/// const AppLoading() +/// +/// // Full-screen loader with message +/// AppLoading(message: 'Fetching data...') +/// ``` +class AppLoading extends StatelessWidget { + const AppLoading({ + super.key, + this.message, + this.size = 28, + this.strokeWidth = 3, + }); + + final String? message; + final double size; + final double strokeWidth; + + @override + Widget build(BuildContext context) { + final cs = context.theme.colorScheme; + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: size, + height: size, + child: CircularProgressIndicator( + strokeWidth: strokeWidth, + color: cs.primary, + ), + ), + if (message != null) ...[ + {{#if flags.usesScreenutil}}SizedBox(height: 16.h){{else}}const SizedBox(height: 16){{/if}}, + Text( + message!, + style: context.theme.textTheme.bodyMedium?.copyWith( + color: cs.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + ], + ], + ), + ); + } +} diff --git a/cli/templates/base/lib/src/shared/widgets/app_text_field.dart.hbs b/cli/templates/base/lib/src/shared/widgets/app_text_field.dart.hbs new file mode 100644 index 0000000..1c01bce --- /dev/null +++ b/cli/templates/base/lib/src/shared/widgets/app_text_field.dart.hbs @@ -0,0 +1,89 @@ +import '../../imports/core_imports.dart'; + + +/// A themed text form field wrapping [TextFormField]. +/// +/// Usage: +/// ```dart +/// AppTextField( +/// label: 'Email', +/// hint: 'you@example.com', +/// controller: _emailController, +/// keyboardType: TextInputType.emailAddress, +/// validator: (v) => v!.isEmpty ? 'Required' : null, +/// ) +/// ``` +class AppTextField extends StatelessWidget { + const AppTextField({ + super.key, + this.label, + this.hint, + this.controller, + this.validator, + this.onChanged, + this.onFieldSubmitted, + this.focusNode, + this.keyboardType, + this.textInputAction, + this.obscureText = false, + this.readOnly = false, + this.enabled = true, + this.maxLines = 1, + this.minLines, + this.prefixIcon, + this.suffixIcon, + this.initialValue, + this.autofocus = false, + }); + + final String? label; + final String? hint; + final TextEditingController? controller; + final FormFieldValidator? validator; + final ValueChanged? onChanged; + final ValueChanged? onFieldSubmitted; + final FocusNode? focusNode; + final TextInputType? keyboardType; + final TextInputAction? textInputAction; + final bool obscureText; + final bool readOnly; + final bool enabled; + final int? maxLines; + final int? minLines; + final Widget? prefixIcon; + final Widget? suffixIcon; + final String? initialValue; + final bool autofocus; + + @override + Widget build(BuildContext context) { + final cs = context.theme.colorScheme; + final tt = context.theme.textTheme; + + return TextFormField( + controller: controller, + initialValue: initialValue, + validator: validator, + onChanged: onChanged, + onFieldSubmitted: onFieldSubmitted, + focusNode: focusNode, + keyboardType: keyboardType, + textInputAction: textInputAction, + obscureText: obscureText, + readOnly: readOnly, + enabled: enabled, + maxLines: obscureText ? 1 : maxLines, + minLines: minLines, + autofocus: autofocus, + style: tt.bodyLarge?.copyWith(color: cs.onSurface), + cursorColor: cs.primary, + decoration: InputDecoration( + isDense: true, + labelText: label, + hintText: hint, + prefixIcon: prefixIcon, + suffixIcon: suffixIcon, + ), + ); + } +} diff --git a/cli/templates/base/lib/src/shared/widgets/app_top_bar.dart.hbs b/cli/templates/base/lib/src/shared/widgets/app_top_bar.dart.hbs new file mode 100644 index 0000000..ac6bff1 --- /dev/null +++ b/cli/templates/base/lib/src/shared/widgets/app_top_bar.dart.hbs @@ -0,0 +1,105 @@ +import '../../imports/imports.dart'; + +class AppTopBar extends StatelessWidget implements PreferredSizeWidget { + const AppTopBar({ + super.key, + required this.title, + this.titleWidget, + this.actions, + this.centerTitle = true, + this.onPressed, + this.isTransparent = false, + }); + + final String title; + final Widget? titleWidget; + final List? actions; + final VoidCallback? onPressed; + final bool? centerTitle; + final bool isTransparent; + + @override + Widget build(BuildContext context) { + final theme = context.theme; + + // Check if we can pop + {{#if (eq flags.routerPackage "go_router")}} + final bool canPop = context.canPop(); + {{else if (eq flags.routerPackage "auto_route")}} + final bool canPop = context.router.canPop(); + {{else}} + final bool canPop = Navigator.canPop(context); + {{/if}} + + void handleBack() { + if (onPressed != null) { + onPressed!(); + } else if (canPop) { + {{#if (eq flags.routerPackage "go_router")}} + context.pop(); + {{else if (eq flags.routerPackage "auto_route")}} + context.router.maybePop(); + {{else}} + Navigator.maybePop(context); + {{/if}} + } else { + {{#if (eq flags.routerPackage "go_router")}} + context.go(AppRoutes.home); + {{else if (eq flags.routerPackage "auto_route")}} + context.router.replaceAll([const HomeRoute()]); + {{else}} + Navigator.pushReplacementNamed(context, AppRoutes.home); + {{/if}} + } + } + + return AppBar( + centerTitle: centerTitle, + elevation: 0, + backgroundColor: isTransparent ? Colors.transparent : null, + shadowColor: Colors.transparent, + title: titleWidget ?? + Text( + title, + style: theme.appBarTheme.titleTextStyle?.copyWith( + fontWeight: FontWeight.w600, + ) ?? theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + leadingWidth: {{res 40 'w' flags.usesScreenutil}}, + leading: GestureDetector( + onTap: handleBack, + child: ColoredBox( + color: Colors.transparent, + child: {{#if flags.usesHugeicons}} + HugeIcon( + icon: HugeIcons.strokeRoundedArrowLeft01, + size: {{res 24 'sp' flags.usesScreenutil}}, + ) + {{else if flags.usesIconsaxPlus}} + Icon( + IconsaxPlusLinear.arrow_left, + color: theme.appBarTheme.iconTheme?.color ?? theme.colorScheme.onSurface, + ) + {{else if flags.usesFlutterRemix}} + Icon( + FlutterRemix.arrow_left_line, + color: theme.appBarTheme.iconTheme?.color ?? theme.colorScheme.onSurface, + ) + {{else}} + Icon( + Icons.arrow_back, + color: theme.appBarTheme.iconTheme?.color ?? theme.colorScheme.onSurface, + ) + {{/if}}, + ), + ), + iconTheme: theme.appBarTheme.iconTheme, + actions: actions ?? [], + ); + } + + @override + Size get preferredSize => const Size.fromHeight(kToolbarHeight); +} diff --git a/cli/templates/base/lib/src/shared/widgets/common_image.dart.hbs b/cli/templates/base/lib/src/shared/widgets/common_image.dart.hbs new file mode 100644 index 0000000..cfd2807 --- /dev/null +++ b/cli/templates/base/lib/src/shared/widgets/common_image.dart.hbs @@ -0,0 +1,120 @@ +import '../../imports/imports.dart'; + + +/// A multi-purpose image widget that handles network images, SVGs, and local assets. +/// +/// Automatically uses [CachedNetworkImage] if enabled for web images. +/// Automatically uses [SvgPicture] if enabled for SVG files. +class CommonImage extends StatelessWidget { + final String imageUrl; + final double? width; + final double? height; + final BoxFit fit; + final Color? color; + final Widget? placeholder; + final Widget? errorWidget; + final BorderRadius? borderRadius; + + const CommonImage({ + super.key, + required this.imageUrl, + this.width, + this.height, + this.fit = BoxFit.cover, + this.color, + this.placeholder, + this.errorWidget, + this.borderRadius, + }); + + @override + Widget build(BuildContext context) { + final double? adjustedWidth = {{#if flags.usesScreenutil}}width?.w{{else}}width{{/if}}; + final double? adjustedHeight = {{#if flags.usesScreenutil}}height?.h{{else}}height{{/if}}; + + Widget image; + + if (imageUrl.startsWith('http')) { + {{#if flags.usesCachedNetworkImage}} + image = AppCachedImage( + imageUrl: imageUrl, + width: width, + height: height, + fit: fit, + color: color, + placeholder: placeholder, + errorWidget: errorWidget, + borderRadius: borderRadius, + ); + {{else}} + image = Image.network( + imageUrl, + width: adjustedWidth, + height: adjustedHeight, + fit: fit, + color: color, + errorBuilder: (context, error, stackTrace) => errorWidget ?? _buildDefaultErrorWidget(), + ); + {{/if}} + } else if (imageUrl.endsWith('.svg')) { + {{#if flags.usesFlutterSvg}} + image = SvgPicture.asset( + imageUrl, + width: adjustedWidth, + height: adjustedHeight, + fit: fit, + colorFilter: color != null ? ColorFilter.mode(color!, BlendMode.srcIn) : null, + ); + {{else}} + image = const Icon(Icons.broken_image, color: Colors.grey); + {{/if}} + } else { + image = Image.asset( + imageUrl, + width: adjustedWidth, + height: adjustedHeight, + fit: fit, + color: color, + errorBuilder: (context, error, stackTrace) => errorWidget ?? _buildDefaultErrorWidget(), + ); + } + + if (borderRadius != null) { + return ClipRRect( + borderRadius: borderRadius!, + child: image, + ); + } + + return image; + } + + Widget _buildDefaultErrorWidget() { + return Container( + width: width, + height: height, + color: Colors.grey[200], + child: {{#if flags.usesHugeicons}} + HugeIcon( + icon: HugeIcons.strokeRoundedImageNotFound02, + size: {{res 24 'sp' flags.usesScreenutil}}, + ) + {{else if flags.usesIconsaxPlus}} + const Icon( + IconsaxPlusBold.image, + color: Colors.grey, + ) + {{else if flags.usesFlutterRemix}} + const Icon( + FlutterRemix.image_line, + color: Colors.grey, + ) + {{else}} + const Icon( + Icons.error_outline, + color: Colors.grey, + ) + {{/if}}, + ); + } +} diff --git a/cli/templates/base/lib/src/shared/widgets/toast/imports.dart.hbs b/cli/templates/base/lib/src/shared/widgets/toast/imports.dart.hbs new file mode 100644 index 0000000..5634b1c --- /dev/null +++ b/cli/templates/base/lib/src/shared/widgets/toast/imports.dart.hbs @@ -0,0 +1,4 @@ +export 'toast.dart'; +export 'toast_card.dart'; +export 'raw_toast.dart'; + diff --git a/cli/templates/base/lib/src/shared/widgets/toast/raw_toast.dart.hbs b/cli/templates/base/lib/src/shared/widgets/toast/raw_toast.dart.hbs new file mode 100644 index 0000000..db2b85a --- /dev/null +++ b/cli/templates/base/lib/src/shared/widgets/toast/raw_toast.dart.hbs @@ -0,0 +1,109 @@ +import '../../../imports/imports.dart'; + +class RawToast extends StatefulWidget { + final Widget child; + final Duration animationDuration; + final Duration snackbarDuration; + final Curve? animationCurve; + final bool autoDismiss; + final ToastPosition toastPosition; + final double Function() getscaleFactor; + final double Function() getPosition; + final void Function() onRemove; + + const RawToast({ + super.key, + required this.child, + required this.animationDuration, + required this.toastPosition, + required this.snackbarDuration, + required this.onRemove, + this.autoDismiss = true, + required this.getPosition, + this.animationCurve, + required this.getscaleFactor, + }); + + @override + State createState() => RawToastState(); +} + +class RawToastState extends State { + final GlobalKey positionedKey = GlobalKey(); + + Widget getChildBasedOnDismiss(Widget child) { + return Animate( + onComplete: (controller) { + if (widget.autoDismiss) { + widget.onRemove(); + } + }, + effects: [ + SlideEffect( + begin: Offset( + 0, + widget.toastPosition == ToastPosition.bottom ? 2 : -2, + ), + end: Offset.zero, + duration: Duration( + milliseconds: 2 * widget.animationDuration.inMilliseconds, + ), + curve: widget.animationCurve ?? Curves.elasticOut, + ), + FadeEffect( + duration: widget.animationDuration, + begin: 0, + end: 1, + ), + if (widget.autoDismiss) + SlideEffect( + delay: widget.snackbarDuration, + duration: const Duration(milliseconds: 500), + curve: widget.animationCurve ?? Curves.easeInOut, + begin: Offset.zero, + end: Offset( + 0, + widget.toastPosition == ToastPosition.bottom ? 2 : -2, + ), + ), + ], + child: Dismissible( + key: UniqueKey(), + direction: DismissDirection.up, + onDismissed: (direction) { + widget.onRemove(); + }, + child: widget.child, + ), + ); + } + + @override + Widget build(BuildContext context) { + return AnimatedPositioned( + duration: Duration( + milliseconds: widget.animationDuration.inMilliseconds, + ), + key: positionedKey, + curve: Curves.easeOutBack, + top: widget.toastPosition == ToastPosition.top + ? widget.getPosition() + 55 + : null, + bottom: widget.toastPosition == ToastPosition.bottom + ? widget.getPosition() + 60 + : null, + left: 0, + right: 0, + child: Material( + color: Colors.transparent, + child: AnimatedScale( + duration: widget.animationDuration, + curve: Curves.bounceOut, + scale: widget.getPosition() == 0 ? 1 : widget.getscaleFactor(), + child: getChildBasedOnDismiss(widget.child), + ), + ), + ); + } +} + diff --git a/cli/templates/base/lib/src/shared/widgets/toast/toast.dart.hbs b/cli/templates/base/lib/src/shared/widgets/toast/toast.dart.hbs new file mode 100644 index 0000000..d297415 --- /dev/null +++ b/cli/templates/base/lib/src/shared/widgets/toast/toast.dart.hbs @@ -0,0 +1,142 @@ +import '../../../imports/imports.dart'; + +/// Enum for toast positions. +enum ToastPosition { top, bottom } + +/// The gap between stack of cards. +int gapBetweenCard = 15; + +/// Calculate position of old cards based on current position. +double calculatePosition(List toastBars, ToastBar self) { + if (toastBars.isNotEmpty && self != toastBars.last) { + final box = + self.info.key.currentContext?.findRenderObject() as RenderBox?; + if (box != null) { + return gapBetweenCard * (toastBars.length - toastBars.indexOf(self) - 1); + } + } + return 0; +} + +/// Rescale the old cards based on its position. +double calculateScaleFactor(List toastBars, ToastBar current) { + final int index = toastBars.indexOf(current); + final int indexValFromLast = toastBars.length - 1 - index; + final double factor = indexValFromLast / 25; + final double res = 0.97 - factor; + return res < 0 ? 0 : res; +} + +List _toastBars = []; + +/// Toast core class. +class ToastBar { + /// Duration of toast when autoDismiss is true. + final Duration toastDuration; + + /// Position of toast. + final ToastPosition position; + + /// Set true to dismiss toast automatically based on toastDuration. + final bool autoDismiss; + + /// Pass the widget inside builder context. + final WidgetBuilder builder; + + /// Duration of animated transitions. + final Duration animationDuration; + + /// Animation Curve. + final Curve? animationCurve; + + /// Info on each toast. + late final SnackBarInfo info; + + /// Initialise ToastBar with required parameters. + ToastBar({ + this.toastDuration = const Duration(milliseconds: 5000), + this.position = ToastPosition.bottom, + required this.builder, + this.animationDuration = const Duration(milliseconds: 700), + this.autoDismiss = false, + this.animationCurve, + }) : assert( + toastDuration.inMilliseconds > animationDuration.inMilliseconds, + ); + + /// Remove individual toastBars on dismiss. + void remove() { + info.entry.remove(); + _toastBars.removeWhere((element) => element == this); + } + + /// Push the toast in current context. + void show(BuildContext context) { + final OverlayState overlayState = Navigator.of(context).overlay!; + info = SnackBarInfo( + key: GlobalKey(), + createdAt: DateTime.now(), + ); + info.entry = OverlayEntry( + builder: (_) => RawToast( + key: info.key, + animationDuration: animationDuration, + toastPosition: position, + animationCurve: animationCurve, + autoDismiss: autoDismiss, + getPosition: () => calculatePosition(_toastBars, this), + getscaleFactor: () => calculateScaleFactor(_toastBars, this), + snackbarDuration: toastDuration, + onRemove: remove, + child: builder.call(context), + ), + ); + + _toastBars.add(this); + overlayState.insert(info.entry); + } + + /// Remove all the toasts in the context. + static void removeAll() { + for (int i = 0; i < _toastBars.length; i++) { + _toastBars[i].info.entry.remove(); + } + _toastBars.removeWhere((element) => true); + } +} + +/// Snackbar info class. +class SnackBarInfo { + late final OverlayEntry entry; + final GlobalKey key; + final DateTime createdAt; + + SnackBarInfo({ + required this.key, + required this.createdAt, + }); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is SnackBarInfo && + other.entry == entry && + other.key == key && + other.createdAt == createdAt; + } + + @override + int get hashCode => entry.hashCode ^ key.hashCode ^ createdAt.hashCode; +} + +/// Get all the toastBars which are currently on context. +extension Cleaner on List { + /// Clean function to iterate over toastBars which are in context. + List clean() { + return where( + (element) => element.info.key.currentState != null, + ).toList(); + } +} + diff --git a/cli/templates/base/lib/src/shared/widgets/toast/toast_card.dart.hbs b/cli/templates/base/lib/src/shared/widgets/toast/toast_card.dart.hbs new file mode 100644 index 0000000..ce616ce --- /dev/null +++ b/cli/templates/base/lib/src/shared/widgets/toast/toast_card.dart.hbs @@ -0,0 +1,57 @@ +import '../../../imports/imports.dart'; + +/// ToastCard widget to display decent and rich looking toast. +class ToastCard extends StatelessWidget { + final Widget title; + final Widget? subtitle; + final Widget? leading; + final Widget? trailing; + final Color? color; + final Color? shadowColor; + final void Function()? onTap; + + const ToastCard({ + super.key, + required this.title, + this.subtitle, + this.leading, + this.color, + this.shadowColor, + this.trailing, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 20), + decoration: BoxDecoration( + color: color ?? context.theme.dialogTheme.backgroundColor, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: context.theme.colorScheme.outline, + ), + boxShadow: [ + BoxShadow( + blurRadius: 10, + spreadRadius: 0, + offset: Offset.zero, + color: shadowColor ?? Colors.black.withValues(alpha: 0.05), + ), + ], + ), + child: ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 8), + leading: Padding( + padding: const EdgeInsets.only(left: 10), + child: leading, + ), + trailing: trailing, + subtitle: subtitle, + title: title, + onTap: onTap, + ), + ); + } +} + diff --git a/cli/templates/base/lib/src/shared/widgets/widgets.dart.hbs b/cli/templates/base/lib/src/shared/widgets/widgets.dart.hbs new file mode 100644 index 0000000..1970089 --- /dev/null +++ b/cli/templates/base/lib/src/shared/widgets/widgets.dart.hbs @@ -0,0 +1,14 @@ +export 'app_icon.dart'; +export 'app_button.dart'; +export 'app_text_field.dart'; +export 'app_loading.dart'; +export 'app_error_widget.dart'; +export 'app_empty_state.dart'; +export 'app_card.dart'; +export 'app_divider.dart'; +export 'app_top_bar.dart'; +export 'common_image.dart'; +export 'toast/imports.dart'; +{{#if flags.usesCachedNetworkImage}} +export 'app_cached_image.dart'; +{{/if}} diff --git a/cli/templates/base/lib/src/shared/wrappers/(isRiverpod,isProvider,isBloc,isGetX,isMobX)@state_wrapper.dart.hbs b/cli/templates/base/lib/src/shared/wrappers/(isRiverpod,isProvider,isBloc,isGetX,isMobX)@state_wrapper.dart.hbs new file mode 100644 index 0000000..9bac652 --- /dev/null +++ b/cli/templates/base/lib/src/shared/wrappers/(isRiverpod,isProvider,isBloc,isGetX,isMobX)@state_wrapper.dart.hbs @@ -0,0 +1,51 @@ +import '../../imports/imports.dart'; +{{#if flags.isBloc}} +import '../../{{#if (eq architecture "layer-first")}}data/repositories/{{else if (eq architecture "mvc")}}services/{{else if (eq architecture "mvvm")}}data/repositories/{{else}}features/auth/data/repositories/{{/if}}auth_repository_impl.dart'; +import '../../{{#if (eq architecture "layer-first")}}presentation/providers/session_bloc.dart{{else if (eq architecture "mvc")}}controllers/auth/session_bloc.dart{{else if (eq architecture "mvvm")}}ui/auth/bloc/session_bloc.dart{{else}}features/auth/presentation/providers/session_bloc.dart{{/if}}'; +{{else if flags.isProvider}} +import '../../{{#if (eq architecture "layer-first")}}data/repositories/{{else if (eq architecture "mvc")}}services/{{else if (eq architecture "mvvm")}}data/repositories/{{else}}features/auth/data/repositories/{{/if}}auth_repository_impl.dart'; +import '../../{{#if (eq architecture "layer-first")}}presentation/providers/session_provider.dart{{else if (eq architecture "mvc")}}controllers/auth/session_provider.dart{{else if (eq architecture "mvvm")}}ui/auth/providers/session_provider.dart{{else}}features/auth/presentation/providers/session_provider.dart{{/if}}'; +{{else if flags.isMobX}} +import '../../{{#if (eq architecture "layer-first")}}data/repositories/{{else if (eq architecture "mvc")}}services/{{else if (eq architecture "mvvm")}}data/repositories/{{else}}features/auth/data/repositories/{{/if}}auth_repository_impl.dart'; +import '../../{{#if (eq architecture "layer-first")}}presentation/providers/session_store.dart{{else if (eq architecture "mvc")}}controllers/auth/session_store.dart{{else if (eq architecture "mvvm")}}ui/auth/stores/session_store.dart{{else}}features/auth/presentation/providers/session_store.dart{{/if}}'; +{{/if}} + +/// A wrapper to initialize the chosen State Management library. +class StateWrapper extends StatelessWidget { + final Widget child; + + const StateWrapper({ + super.key, + required this.child, + }); + + @override + Widget build(BuildContext context) { + {{#if flags.isRiverpod}} + return ProviderScope(child: child); + {{else if flags.isProvider}} + return MultiProvider( + providers: [ + ChangeNotifierProvider(create: (_) => SessionProvider(repository: AuthRepositoryImpl())), + ], + child: child, + ); + {{else if flags.isBloc}} + return MultiBlocProvider( + providers: [ + BlocProvider(create: (_) => SessionBloc(repository: AuthRepositoryImpl())), + ], + child: child, + ); + {{else if flags.isMobX}} + return MultiProvider( + providers: [ + Provider(create: (_) => SessionStore(repository: AuthRepositoryImpl())), + ], + child: child, + ); + {{else}} + return child; + {{/if}} + } +} diff --git a/cli/templates/base/lib/src/shared/wrappers/(supportsLocalization)@localization_wrapper.dart.hbs b/cli/templates/base/lib/src/shared/wrappers/(supportsLocalization)@localization_wrapper.dart.hbs new file mode 100644 index 0000000..11999f4 --- /dev/null +++ b/cli/templates/base/lib/src/shared/wrappers/(supportsLocalization)@localization_wrapper.dart.hbs @@ -0,0 +1,25 @@ +import '../../imports/core_imports.dart'; + +/// A wrapper to initialize [EasyLocalization] with supported locales. +class LocalizationWrapper extends StatelessWidget { + final Widget child; + + const LocalizationWrapper({ + super.key, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return EasyLocalization( + supportedLocales: const [ + {{#each flags.supportedLocales}} + Locale('{{this}}'), + {{/each}} + ], + path: 'assets/translations', + fallbackLocale: const Locale('{{flags.fallbackLocale}}'), + child: child, + ); + } +} diff --git a/cli/templates/base/lib/src/shared/wrappers/(usesScreenutil)@screen_util_wrapper.dart.hbs b/cli/templates/base/lib/src/shared/wrappers/(usesScreenutil)@screen_util_wrapper.dart.hbs new file mode 100644 index 0000000..20628d3 --- /dev/null +++ b/cli/templates/base/lib/src/shared/wrappers/(usesScreenutil)@screen_util_wrapper.dart.hbs @@ -0,0 +1,27 @@ +import '../../imports/imports.dart'; + +/// A wrapper to initialize [ScreenUtil] with design-specific constraints. +class ScreenUtilWrapper extends StatelessWidget { + final Widget child; + final Size designSize; + final bool minTextAdapt; + final bool splitScreenMode; + + const ScreenUtilWrapper({ + super.key, + required this.child, + this.designSize = const Size(360, 690), + this.minTextAdapt = true, + this.splitScreenMode = true, + }); + + @override + Widget build(BuildContext context) { + return ScreenUtilInit( + designSize: designSize, + minTextAdapt: minTextAdapt, + splitScreenMode: splitScreenMode, + builder: (context, _) => child, + ); + } +} diff --git a/cli/templates/base/lib/src/shared/wrappers/(usesSkeletonizer)@skeleton_wrapper.dart.hbs b/cli/templates/base/lib/src/shared/wrappers/(usesSkeletonizer)@skeleton_wrapper.dart.hbs new file mode 100644 index 0000000..112ed4b --- /dev/null +++ b/cli/templates/base/lib/src/shared/wrappers/(usesSkeletonizer)@skeleton_wrapper.dart.hbs @@ -0,0 +1,81 @@ +import '../../imports/imports.dart'; + +/// A wrapper widget that provides skeleton loading effects. +/// +/// Uses [Skeletonizer] if enabled. +class SkeletonWrapper extends StatelessWidget { + final Widget child; + final bool isLoading; + final bool enabled; + + /// Custom skeleton effect (e.g., ShimmerEffect, PulseEffect) + final ShimmerEffect? effect; + + /// Whether to animate the transition between skeleton and content + final bool enableSwitchAnimation; + + /// Configuration for the switch animation + final SwitchAnimationConfig? switchAnimationConfig; + + /// Whether to justify multi-line text bones + final bool? justifyMultiLineText; + + /// Whether to ignore all containers and only skeletonize their children + final bool ignoreContainers; + + /// If provided, all containers will be painted with this color + final Color? containersColor; + + /// The border radius of the text bones + final TextBoneBorderRadius? textBoneBorderRadius; + + /// Whether to ignore pointers when loading + final bool ignorePointers; + + const SkeletonWrapper({ + super.key, + required this.child, + this.isLoading = false, + this.enabled = true, + this.effect, + this.enableSwitchAnimation = false, + this.switchAnimationConfig, + this.justifyMultiLineText, + this.ignoreContainers = false, + this.containersColor, + this.textBoneBorderRadius, + this.ignorePointers = true, + }); + + @override + Widget build(BuildContext context) { + if (!enabled) return child; + + {{#if flags.usesSkeletonizer}} + return Skeletonizer( + enabled: isLoading, + effect: effect, + enableSwitchAnimation: enableSwitchAnimation, + switchAnimationConfig: switchAnimationConfig, + justifyMultiLineText: justifyMultiLineText, + ignoreContainers: ignoreContainers, + containersColor: containersColor, + textBoneBorderRadius: textBoneBorderRadius, + ignorePointers: ignorePointers, + child: child, + ); + {{else}} + if (isLoading) { + // Simple fallback for loading state if skeletonizer is not enabled + return AbsorbPointer( + absorbing: ignorePointers, + child: Opacity( + opacity: 0.5, + child: child, + ), + ); + } + return child; + {{/if}} + } +} diff --git a/cli/templates/base/lib/src/shared/wrappers/session_listener_wrapper.dart.hbs b/cli/templates/base/lib/src/shared/wrappers/session_listener_wrapper.dart.hbs new file mode 100644 index 0000000..6c35c75 --- /dev/null +++ b/cli/templates/base/lib/src/shared/wrappers/session_listener_wrapper.dart.hbs @@ -0,0 +1,258 @@ +import 'package:{{flags.appSnake}}/src/imports/core_imports.dart'; +{{#unless flags.isNoneState}} +import 'package:{{flags.appSnake}}/src/imports/packages_imports.dart'; +{{/unless}} + +{{#if flags.isRiverpod}} +import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}presentation/providers/session_provider.dart{{else if (eq architecture "mvc")}}controllers/auth/session_provider.dart{{else if (eq architecture "mvvm")}}ui/auth/providers/session_provider.dart{{else}}features/auth/presentation/providers/session_provider.dart{{/if}}'; +{{else if flags.isBloc}} +import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}presentation/providers/session_bloc.dart{{else if (eq architecture "mvc")}}controllers/auth/session_bloc.dart{{else if (eq architecture "mvvm")}}ui/auth/bloc/session_bloc.dart{{else}}features/auth/presentation/providers/session_bloc.dart{{/if}}'; +{{else if flags.isGetX}} +import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}presentation/providers/session_controller.dart{{else if (eq architecture "mvc")}}controllers/auth/session_controller.dart{{else if (eq architecture "mvvm")}}ui/auth/controllers/session_controller.dart{{else}}features/auth/presentation/providers/session_controller.dart{{/if}}'; +{{else if flags.isMobX}} +import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}presentation/providers/session_store.dart{{else if (eq architecture "mvc")}}controllers/auth/session_store.dart{{else if (eq architecture "mvvm")}}ui/auth/stores/session_store.dart{{else}}features/auth/presentation/providers/session_store.dart{{/if}}'; +{{else if flags.isProvider}} +import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}presentation/providers/session_provider.dart{{else if (eq architecture "mvc")}}controllers/auth/session_provider.dart{{else if (eq architecture "mvvm")}}ui/auth/providers/session_provider.dart{{else}}features/auth/presentation/providers/session_provider.dart{{/if}}'; +{{else if flags.isNoneState}} +// No session listener needed for NoneState — widget rebuilds are manual +{{else}} +// session_manager not easily retrieved via context globally if not using Provider, assuming single instance +import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}presentation/providers/session_manager.dart{{else if (eq architecture "mvc")}}controllers/auth/session_manager.dart{{else if (eq architecture "mvvm")}}ui/auth/view_models/session_manager.dart{{else}}features/auth/presentation/providers/session_manager.dart{{/if}}'; +{{/if}} + + +{{#if flags.isRiverpod}} +class SessionListenerWrapper extends ConsumerWidget { + final Widget child; + const SessionListenerWrapper({super.key, required this.child}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + ref.listen(sessionProvider, (prev, next) { + if (next.status != SessionStatus.unknown) { + {{#if flags.usesFlutterNativeSplash}} + FlutterNativeSplash.remove(); + {{/if}} + if (next.status == SessionStatus.authenticated) { + {{#if (eq flags.routerPackage "go_router")}} + context.go(AppRoutes.home); + {{else if (eq flags.routerPackage "auto_route")}} + context.replaceRoute(const HomeRoute()); + {{else}} + Navigator.pushReplacementNamed(context, AppRoutes.home); + {{/if}} + } else if (next.status == SessionStatus.unauthenticated) { + {{#if (eq flags.routerPackage "go_router")}} + context.go(AppRoutes.onboarding); + {{else if (eq flags.routerPackage "auto_route")}} + context.replaceRoute(const OnboardingRoute()); + {{else}} + Navigator.pushReplacementNamed(context, AppRoutes.onboarding); + {{/if}} + } + } + }); + + return child; + } +} +{{else if flags.isBloc}} +class SessionListenerWrapper extends StatelessWidget { + final Widget child; + const SessionListenerWrapper({super.key, required this.child}); + + @override + Widget build(BuildContext context) { + return BlocListener( + listenWhen: (prev, next) => prev.status != next.status, + listener: (context, state) { + if (state.status != SessionStatus.unknown) { + {{#if flags.usesFlutterNativeSplash}} + FlutterNativeSplash.remove(); + {{/if}} + if (state.status == SessionStatus.authenticated) { + {{#if (eq flags.routerPackage "go_router")}} + context.go(AppRoutes.home); + {{else if (eq flags.routerPackage "auto_route")}} + context.replaceRoute(const HomeRoute()); + {{else}} + Navigator.pushReplacementNamed(context, AppRoutes.home); + {{/if}} + } else if (state.status == SessionStatus.unauthenticated) { + {{#if (eq flags.routerPackage "go_router")}} + context.go(AppRoutes.onboarding); + {{else if (eq flags.routerPackage "auto_route")}} + context.replaceRoute(const OnboardingRoute()); + {{else}} + Navigator.pushReplacementNamed(context, AppRoutes.onboarding); + {{/if}} + } + } + }, + child: child, + ); + } +} +{{else if flags.isGetX}} +class SessionListenerWrapper extends StatefulWidget { + final Widget child; + const SessionListenerWrapper({super.key, required this.child}); + + @override + State createState() => _SessionListenerWrapperState(); +} + +class _SessionListenerWrapperState extends State { + Worker? _worker; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + final session = Get.find(); + _worker = ever(session.status, (status) { + if (status != SessionStatus.unknown) { + {{#if flags.usesFlutterNativeSplash}} + FlutterNativeSplash.remove(); + {{/if}} + if (status == SessionStatus.authenticated) { + Get.offAllNamed(AppRoutes.home); + } else if (status == SessionStatus.unauthenticated) { + Get.offAllNamed(AppRoutes.onboarding); + } + } + }, condition: () => session.status.value != SessionStatus.unknown); + }); + } + + @override + void dispose() { + _worker?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return widget.child; + } +} +{{else if flags.isMobX}} +class SessionListenerWrapper extends StatefulWidget { + final Widget child; + const SessionListenerWrapper({super.key, required this.child}); + + @override + State createState() => _SessionListenerWrapperState(); +} + +class _SessionListenerWrapperState extends State { + late final ReactionDisposer _disposer; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final sessionStore = Provider.of(context, listen: false); + _disposer = reaction( + (_) => sessionStore.status, + (SessionStatus status) { + if (status != SessionStatus.unknown) { + {{#if flags.usesFlutterNativeSplash}} + FlutterNativeSplash.remove(); + {{/if}} + if (status == SessionStatus.authenticated) { + {{#if (eq flags.routerPackage "go_router")}} + context.go(AppRoutes.home); + {{else if (eq flags.routerPackage "auto_route")}} + context.replaceRoute(const HomeRoute()); + {{else}} + Navigator.pushReplacementNamed(context, AppRoutes.home); + {{/if}} + } else if (status == SessionStatus.unauthenticated) { + {{#if (eq flags.routerPackage "go_router")}} + context.go(AppRoutes.onboarding); + {{else if (eq flags.routerPackage "auto_route")}} + context.replaceRoute(const OnboardingRoute()); + {{else}} + Navigator.pushReplacementNamed(context, AppRoutes.onboarding); + {{/if}} + } + } + }, + fireImmediately: true, + ); + } + + @override + void dispose() { + _disposer(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return widget.child; + } +} +{{else if flags.isProvider}} +class SessionListenerWrapper extends StatefulWidget { + final Widget child; + const SessionListenerWrapper({super.key, required this.child}); + + @override + State createState() => _SessionListenerWrapperState(); +} + +class _SessionListenerWrapperState extends State { + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final session = Provider.of(context); + if (session.status != SessionStatus.unknown) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + {{#if flags.usesFlutterNativeSplash}} + FlutterNativeSplash.remove(); + {{/if}} + if (session.status == SessionStatus.authenticated) { + {{#if (eq flags.routerPackage "go_router")}} + context.go(AppRoutes.home); + {{else if (eq flags.routerPackage "auto_route")}} + context.replaceRoute(const HomeRoute()); + {{else}} + Navigator.pushReplacementNamed(context, AppRoutes.home); + {{/if}} + } else if (session.status == SessionStatus.unauthenticated) { + {{#if (eq flags.routerPackage "go_router")}} + context.go(AppRoutes.onboarding); + {{else if (eq flags.routerPackage "auto_route")}} + context.replaceRoute(const OnboardingRoute()); + {{else}} + Navigator.pushReplacementNamed(context, AppRoutes.onboarding); + {{/if}} + } + }); + } + } + + @override + Widget build(BuildContext context) { + return widget.child; + } +} +{{else}} +class SessionListenerWrapper extends StatefulWidget { + final Widget child; + const SessionListenerWrapper({super.key, required this.child}); + + @override + State createState() => _SessionListenerWrapperState(); +} + +class _SessionListenerWrapperState extends State { + // Logic left to implement for vanilla flutter. Ideally pass SessionManager logic. + @override + Widget build(BuildContext context) { + return widget.child; + } +} +{{/if}} diff --git a/cli/templates/base/lib/src/shared/wrappers/wrappers.dart.hbs b/cli/templates/base/lib/src/shared/wrappers/wrappers.dart.hbs new file mode 100644 index 0000000..b6a467c --- /dev/null +++ b/cli/templates/base/lib/src/shared/wrappers/wrappers.dart.hbs @@ -0,0 +1,15 @@ + +{{#if flags.usesScreenutil}} +export 'screen_util_wrapper.dart'; +{{/if}} +{{#if flags.usesSkeletonizer}} +export 'skeleton_wrapper.dart'; +{{/if}} +{{#if flags.supportsLocalization}} +export 'localization_wrapper.dart'; +{{/if}} +{{#if (or flags.isRiverpod flags.isProvider flags.isBloc flags.isGetX flags.isMobX)}} +export 'state_wrapper.dart'; +{{/if}} + +export 'session_listener_wrapper.dart'; diff --git a/cli/templates/base/lib/src/theme/app_borders.dart.hbs b/cli/templates/base/lib/src/theme/app_borders.dart.hbs new file mode 100644 index 0000000..478a0b2 --- /dev/null +++ b/cli/templates/base/lib/src/theme/app_borders.dart.hbs @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; + +/// Reusable border radii and border shapes used across the app. +/// +/// Usage: +/// ```dart +/// Container(decoration: BoxDecoration(borderRadius: AppBorders.md)) +/// ``` +abstract final class AppBorders { + AppBorders._(); + + // ── Border Radii ────────────────────────────────────────────────────────── + + /// 4 pt — subtle rounding, used for small chips, badges. + static const BorderRadius xs = BorderRadius.all(Radius.circular(4)); + + /// 8 pt — standard rounding for buttons, text fields. + static const BorderRadius sm = BorderRadius.all(Radius.circular(8)); + + /// 12 pt — medium rounding for cards, list tiles. + static const BorderRadius md = BorderRadius.all(Radius.circular(12)); + + /// 16 pt — large rounding for modals, bottom sheets. + static const BorderRadius lg = BorderRadius.all(Radius.circular(16)); + + /// 24 pt — extra large rounding for dialogs, feature cards. + static const BorderRadius xl = BorderRadius.all(Radius.circular(24)); + + /// 28 pt — Material 3 bottom sheet top radius. + static const BorderRadius bottomSheet = BorderRadius.vertical( + top: Radius.circular(28), + ); + + /// Fully circular (pill/stadium shape). + static const BorderRadius full = BorderRadius.all(Radius.circular(999)); + + // ── Semantic aliases ────────────────────────────────────────────────────── + + /// Default button border radius. + static const BorderRadius button = lg; + + /// Default card border radius. + static const BorderRadius card = md; + + /// Default input field border radius. + static const BorderRadius input = sm; + + /// Default dialog border radius. + static const BorderRadius dialog = xl; + + // ── RoundedRectangleBorder shapes (for ShapeBorder APIs) ───────────────── + + /// Small rounded rectangle shape (8 pt). + static const RoundedRectangleBorder shapeSm = RoundedRectangleBorder( + borderRadius: sm, + ); + + /// Medium rounded rectangle shape (12 pt). + static const RoundedRectangleBorder shapeMd = RoundedRectangleBorder( + borderRadius: md, + ); + + /// Large rounded rectangle shape (16 pt). + static const RoundedRectangleBorder shapeLg = RoundedRectangleBorder( + borderRadius: lg, + ); + + /// Fully circular/stadium shape. + static const StadiumBorder stadium = StadiumBorder(); +} diff --git a/cli/templates/base/lib/src/theme/app_curves.dart.hbs b/cli/templates/base/lib/src/theme/app_curves.dart.hbs new file mode 100644 index 0000000..d38f6b6 --- /dev/null +++ b/cli/templates/base/lib/src/theme/app_curves.dart.hbs @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; + +/// Named animation curves following Material 3 motion guidelines. +/// +/// Material 3 defines four core easing families: +/// - **Standard** — most UI transitions +/// - **Emphasized** — important / expressive transitions +/// - **Decelerate** — elements entering the screen +/// - **Accelerate** — elements leaving the screen +/// +/// Usage: +/// ```dart +/// AnimatedContainer( +/// duration: AppDurations.normal, +/// curve: AppCurves.standard, +/// ) +/// ``` +abstract final class AppCurves { + AppCurves._(); + + // ── Material 3 Core Easing ──────────────────────────────────────────────── + + /// Standard easing — for most UI transitions (enter + exit both). + /// Equivalent to M3 "Standard" curve. + static const Curve standard = Curves.easeInOut; + + /// Emphasized easing — for important, expressive transitions. + /// More dramatic entry, used for large elements or hero moments. + static const Curve emphasized = Curves.easeInOutCubicEmphasized; + + /// Decelerate — for elements entering the screen (fly-in). + /// Starts fast, slows down to a gentle stop. + static const Curve decelerate = Curves.decelerate; + + /// Accelerate — for elements leaving the screen (fly-out). + /// Starts slow, picks up speed as it exits. + static const Curve accelerate = Curves.easeIn; + + // ── Additional utility curves ───────────────────────────────────────────── + + /// Spring-like overshoot — fun, bouncy interactions (FABs, cards). + static const Curve spring = Curves.elasticOut; + + /// Ease out back — slight overshoot then settle; good for scaling pop-ins. + static const Curve easeOutBack = Curves.easeOutBack; + + /// Linear — only for continuous loops (loaders, shimmer). + static const Curve linear = Curves.linear; + + /// Ease in out cubic — smooth, natural-feeling transitions. + static const Curve smooth = Curves.easeInOutCubic; + + // ── Semantic aliases ────────────────────────────────────────────────────── + + /// For page enter transitions. + static const Curve pageEnter = decelerate; + + /// For page exit transitions. + static const Curve pageExit = accelerate; + + /// For popups and dialogs appearing. + static const Curve popupOpen = emphasized; + + /// For modals and sheets closing. + static const Curve popupClose = standard; + + /// For micro-interactions (button press, toggle). + static const Curve microInteraction = easeOutBack; +} diff --git a/cli/templates/base/lib/src/theme/app_durations.dart.hbs b/cli/templates/base/lib/src/theme/app_durations.dart.hbs new file mode 100644 index 0000000..283a72e --- /dev/null +++ b/cli/templates/base/lib/src/theme/app_durations.dart.hbs @@ -0,0 +1,48 @@ +/// Named animation duration constants following Material 3 motion guidelines. +/// +/// Usage: +/// ```dart +/// AnimatedContainer(duration: AppDurations.normal, curve: AppCurves.standard) +/// ``` +abstract final class AppDurations { + AppDurations._(); + + /// 50 ms — nearly instant, icon state swaps. + static const Duration instant = Duration(milliseconds: 50); + + /// 100 ms — very fast micro-interaction (ripple, press feedback). + static const Duration veryFast = Duration(milliseconds: 100); + + /// 150 ms — fast transition for small, contained elements (checkboxes, chips). + static const Duration fast = Duration(milliseconds: 150); + + /// 200 ms — quick transition, tab indicator, list item highlight. + static const Duration quick = Duration(milliseconds: 200); + + /// 300 ms — standard transition for most UI animations. + /// Follows Material 3 "standard" motion duration guideline. + static const Duration normal = Duration(milliseconds: 300); + + /// 400 ms — medium transition for page elements entering from off-screen. + static const Duration medium = Duration(milliseconds: 400); + + /// 500 ms — slow, deliberate reveal of large surfaces (drawers, dialogs). + static const Duration slow = Duration(milliseconds: 500); + + /// 700 ms — very slow, for complex choreographed sequences. + static const Duration verySlow = Duration(milliseconds: 700); + + /// 1000 ms — skeleton/shimmer cycle base duration. + static const Duration shimmer = Duration(milliseconds: 1000); + + // ── Semantic aliases ────────────────────────────────────────────────────── + + /// Recommended duration for page transitions. + static const Duration pageTransition = medium; + + /// Recommended duration for in-place widget transitions (show/hide). + static const Duration widgetTransition = normal; + + /// Recommended duration for micro-interactions (button press, hover). + static const Duration microInteraction = fast; +} diff --git a/cli/templates/base/lib/src/theme/app_shadows.dart.hbs b/cli/templates/base/lib/src/theme/app_shadows.dart.hbs new file mode 100644 index 0000000..1dac8ce --- /dev/null +++ b/cli/templates/base/lib/src/theme/app_shadows.dart.hbs @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; + +/// Predefined box shadows aligned with Material 3 elevation tiers. +/// +/// Usage: +/// ```dart +/// Container( +/// decoration: BoxDecoration( +/// boxShadow: AppShadows.card, +/// ), +/// ) +/// ``` +abstract final class AppShadows { + AppShadows._(); + + /// No shadow — flat, tonal surface (elevation 0). + static const List none = []; + + /// Minimal shadow — barely lifted surfaces (elevation 1). + /// Use for: toggle surfaces, filled cards on white background. + static const List subtle = [ + BoxShadow( + color: Color(0x0D000000), // 5% black + blurRadius: 2, + offset: Offset(0, 1), + ), + ]; + + /// Card shadow — clearly elevated content (elevation 2–3). + /// Use for: cards, list items that are tappable, floating elements. + static const List card = [ + BoxShadow( + color: Color(0x14000000), // 8% black + blurRadius: 8, + offset: Offset(0, 2), + ), + BoxShadow( + color: Color(0x0A000000), // 4% black + blurRadius: 2, + offset: Offset(0, 1), + ), + ]; + + /// Elevated shadow — significantly raised surface (elevation 6–8). + /// Use for: FABs, dropdown menus, tooltips. + static const List elevated = [ + BoxShadow( + color: Color(0x1F000000), // 12% black + blurRadius: 16, + offset: Offset(0, 6), + ), + BoxShadow( + color: Color(0x0F000000), // 6% black + blurRadius: 4, + offset: Offset(0, 2), + ), + ]; + + /// Modal shadow — overlay surfaces, dialogs, bottom sheets (elevation 12+). + /// Use for: dialogs, modals, side sheets. + static const List modal = [ + BoxShadow( + color: Color(0x29000000), // 16% black + blurRadius: 32, + offset: Offset(0, 12), + ), + BoxShadow( + color: Color(0x14000000), // 8% black + blurRadius: 8, + offset: Offset(0, 4), + ), + ]; +} diff --git a/cli/templates/base/lib/src/theme/app_spacing.dart.hbs b/cli/templates/base/lib/src/theme/app_spacing.dart.hbs new file mode 100644 index 0000000..5f3862b --- /dev/null +++ b/cli/templates/base/lib/src/theme/app_spacing.dart.hbs @@ -0,0 +1,80 @@ +{{#if flags.usesScreenutil}} +import 'package:flutter_screenutil/flutter_screenutil.dart'; +{{/if}} + +/// Named spacing constants aligned with an 8-pt grid. +/// +/// Usage: +/// ```dart +/// SizedBox(height: AppSpacing.md) // 16 pt gap +/// Padding(padding: EdgeInsets.all(AppSpacing.lg)) +/// ``` +abstract final class AppSpacing { + AppSpacing._(); + + static const double _xxs = 2; + static const double _xs = 4; + static const double _sm = 8; + static const double _ms = 12; + static const double _md = 16; + static const double _ml = 20; + static const double _lg = 24; + static const double _xl = 32; + static const double _xxl = 48; + static const double _xxxl = 64; + + /// 2 pt — hairline gap, icon-to-label spacing. + static double get xxs => {{#if flags.usesScreenutil}}_xxs.r{{else}}_xxs{{/if}}; + + /// 4 pt — tightest spacing, between tightly coupled elements. + static double get xs => {{#if flags.usesScreenutil}}_xs.r{{else}}_xs{{/if}}; + + /// 8 pt — small spacing, inside compact components (chip padding, icon gap). + static double get sm => {{#if flags.usesScreenutil}}_sm.r{{else}}_sm{{/if}}; + + /// 12 pt — medium-small, inner card padding on dense layouts. + static double get ms => {{#if flags.usesScreenutil}}_ms.r{{else}}_ms{{/if}}; + + /// 16 pt — base unit, standard component padding and list item gaps. + static double get md => {{#if flags.usesScreenutil}}_md.r{{else}}_md{{/if}}; + + /// 20 pt — medium-large, comfortable section spacing. + static double get ml => {{#if flags.usesScreenutil}}_ml.r{{else}}_ml{{/if}}; + + /// 24 pt — large, between content sections on a page. + static double get lg => {{#if flags.usesScreenutil}}_lg.r{{else}}_lg{{/if}}; + + /// 32 pt — extra large, major section breaks or hero padding. + static double get xl => {{#if flags.usesScreenutil}}_xl.r{{else}}_xl{{/if}}; + + /// 48 pt — 2× large, top-of-page safe area offsets, empty state padding. + static double get xxl => {{#if flags.usesScreenutil}}_xxl.r{{else}}_xxl{{/if}}; + + /// 64 pt — maximum, full-bleed header heights. + static double get xxxl => {{#if flags.usesScreenutil}}_xxxl.r{{else}}_xxxl{{/if}}; + + // ── Semantic aliases ────────────────────────────────────────────────────── + + /// Standard horizontal page margin. + static double get pagePadding => md; + + /// Gap between list/grid items. + static double get itemGap => sm; + + /// Inner padding for cards. + static double get cardPadding => md; + + /// Vertical gap between form fields. + static double get formFieldGap => ms; +} + +{{#unless flags.usesScreenutil}} +/// Extension to provide identity scaling when ScreenUtil is disabled. +/// This allows using .h, .w, .sp, .r on numbers without conditional checks. +extension ResponsiveNumberExtension on num { + double get h => toDouble(); + double get w => toDouble(); + double get sp => toDouble(); + double get r => toDouble(); +} +{{/unless}} diff --git a/cli/templates/base/lib/src/theme/color_schemes.dart.hbs b/cli/templates/base/lib/src/theme/color_schemes.dart.hbs new file mode 100644 index 0000000..734db6d --- /dev/null +++ b/cli/templates/base/lib/src/theme/color_schemes.dart.hbs @@ -0,0 +1,126 @@ +import 'package:flutter/material.dart'; + +/// App-specific colors that aren't part of the standard [ColorScheme]. +/// Access via `context.appColors` (defined in `context_extension.dart`). +class AppColorsExtension extends ThemeExtension { + const AppColorsExtension({ + required this.success, + required this.onSuccess, + required this.warning, + required this.onWarning, + required this.info, + required this.onInfo, + this.successContainer, + this.onSuccessContainer, + this.warningContainer, + this.onWarningContainer, + this.infoContainer, + this.onInfoContainer, + }); + + final Color success; + final Color onSuccess; + final Color warning; + final Color onWarning; + final Color info; + final Color onInfo; + final Color? successContainer; + final Color? onSuccessContainer; + final Color? warningContainer; + final Color? onWarningContainer; + final Color? infoContainer; + final Color? onInfoContainer; + + @override + ThemeExtension copyWith({ + Color? success, + Color? onSuccess, + Color? warning, + Color? onWarning, + Color? info, + Color? onInfo, + Color? successContainer, + Color? onSuccessContainer, + Color? warningContainer, + Color? onWarningContainer, + Color? infoContainer, + Color? onInfoContainer, + }) { + return AppColorsExtension( + success: success ?? this.success, + onSuccess: onSuccess ?? this.onSuccess, + warning: warning ?? this.warning, + onWarning: onWarning ?? this.onWarning, + info: info ?? this.info, + onInfo: onInfo ?? this.onInfo, + successContainer: successContainer ?? this.successContainer, + onSuccessContainer: onSuccessContainer ?? this.onSuccessContainer, + warningContainer: warningContainer ?? this.warningContainer, + onWarningContainer: onWarningContainer ?? this.onWarningContainer, + infoContainer: infoContainer ?? this.infoContainer, + onInfoContainer: onInfoContainer ?? this.onInfoContainer, + ); + } + + @override + ThemeExtension lerp( + covariant ThemeExtension? other, + double t, + ) { + if (other is! AppColorsExtension) { + return this; + } + return AppColorsExtension( + success: Color.lerp(success, other.success, t)!, + onSuccess: Color.lerp(onSuccess, other.onSuccess, t)!, + warning: Color.lerp(warning, other.warning, t)!, + onWarning: Color.lerp(onWarning, other.onWarning, t)!, + info: Color.lerp(info, other.info, t)!, + onInfo: Color.lerp(onInfo, other.onInfo, t)!, + successContainer: Color.lerp(successContainer, other.successContainer, t), + onSuccessContainer: Color.lerp(onSuccessContainer, other.onSuccessContainer, t), + warningContainer: Color.lerp(warningContainer, other.warningContainer, t), + onWarningContainer: Color.lerp(onWarningContainer, other.onWarningContainer, t), + infoContainer: Color.lerp(infoContainer, other.infoContainer, t), + onInfoContainer: Color.lerp(onInfoContainer, other.onInfoContainer, t), + ); + } +} + +/// Helper class to define the actual color palettes +class AppPalettes { + AppPalettes._(); + + static const light = AppColorsExtension( + success: Color(0xFF2E7D32), + onSuccess: Colors.white, + successContainer: Color(0xFFA5D6A7), + onSuccessContainer: Color(0xFF1B5E20), + warning: Color(0xFFED6C02), + onWarning: Colors.white, + warningContainer: Color(0xFFFFCC80), + onWarningContainer: Color(0xFFE65100), + info: Color(0xFF0288D1), + onInfo: Colors.white, + infoContainer: Color(0xFF81D4FA), + onInfoContainer: Color(0xFF01579B), + ); + + static const dark = AppColorsExtension( + success: Color(0xFF81C784), + onSuccess: Color(0xFF003300), + successContainer: Color(0xFF1B5E20), + onSuccessContainer: Color(0xFFA5D6A7), + warning: Color(0xFFFFB74D), + onWarning: Color(0xFF5D4037), + warningContainer: Color(0xFFE65100), + onWarningContainer: Color(0xFFFFCC80), + info: Color(0xFF4FC3F7), + onInfo: Color(0xFF01579B), + infoContainer: Color(0xFF0277BD), + onInfoContainer: Color(0xFFE1F5FE), + ); +} + +/// Access semantic colors via `context.appColors` from `context_extension.dart`. +/// Example: `context.appColors.success` \ No newline at end of file diff --git a/cli/templates/base/lib/src/theme/text_theme.dart.hbs b/cli/templates/base/lib/src/theme/text_theme.dart.hbs new file mode 100644 index 0000000..bc25619 --- /dev/null +++ b/cli/templates/base/lib/src/theme/text_theme.dart.hbs @@ -0,0 +1,167 @@ +import 'package:flutter/material.dart'; + +/// Defines the full Material 3 typescale for the application. +/// +/// Prefer accessing styles through [BuildContext] extensions: +/// ```dart +/// Text('Hello', style: context.textTheme.titleLarge); +/// ``` +TextTheme buildTextTheme() { + const baseTextTheme = TextTheme( + // ── Display ────────────────────────────────────────────────────────────── + // Use for the largest, most impactful text on a screen. + // Typically reserved for hero/marketing sections, splash screens, + // or short one-word statements. Never use for body copy. + + /// 57 sp — Largest display text. + /// Use for: splash screen titles, giant counters, hero numbers. + displayLarge: TextStyle( + fontSize: 57, + fontWeight: FontWeight.w400, + letterSpacing: -0.25, + ), + + /// 45 sp — Mid-size display text. + /// Use for: prominent feature headlines, onboarding first-screen titles. + displayMedium: TextStyle( + fontSize: 45, + fontWeight: FontWeight.w400, + letterSpacing: 0, + ), + + /// 36 sp — Smallest display text. + /// Use for: section-level hero text, large marketing callouts. + displaySmall: TextStyle( + fontSize: 36, + fontWeight: FontWeight.w400, + letterSpacing: 0, + ), + + // ── Headline ───────────────────────────────────────────────────────────── + // Use for page/screen-level headings. Slightly smaller than Display — + // works well as the primary title of a full page or major section. + + /// 32 sp — Large page-level heading. + /// Use for: main screen titles (e.g. "My Profile"), large dialog headers. + headlineLarge: TextStyle( + fontSize: 32, + fontWeight: FontWeight.w400, + letterSpacing: 0, + ), + + /// 28 sp — Standard page-level heading. + /// Use for: AppBar titles on content-heavy screens, sheet headers. + headlineMedium: TextStyle( + fontSize: 28, + fontWeight: FontWeight.w400, + letterSpacing: 0, + ), + + /// 24 sp — Compact page-level heading. + /// Use for: section headings within a scrollable page, card group titles. + headlineSmall: TextStyle( + fontSize: 24, + fontWeight: FontWeight.w400, + letterSpacing: 0, + ), + + // ── Title ───────────────────────────────────────────────────────────────── + // Use for component-level titles and prominent labels inside cards, + // lists, or dialogs. More emphasis than body, less than headline. + + /// 22 sp — Prominent component title. + /// Use for: AppBar titles (standard), large list-section headers, + /// dialog/modal titles. + titleLarge: TextStyle( + fontSize: 22, + fontWeight: FontWeight.w500, + letterSpacing: 0, + ), + + /// 16 sp — Standard component title. + /// Use for: ListTile titles, card headings, tab labels, dropdown labels. + titleMedium: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + letterSpacing: 0.15, + ), + + /// 14 sp — Compact component title. + /// Use for: dense list item titles, chip labels, form field labels, + /// subtitle of a section. + titleSmall: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + letterSpacing: 0.1, + ), + + // ── Body ────────────────────────────────────────────────────────────────── + // Use for reading/content text. Optimised for legibility at comfortable + // reading sizes. Default for paragraphs and descriptions. + + /// 16 sp — Primary body text. + /// Use for: main paragraph text, message bubbles, article content, + /// default Text() inside cards. + bodyLarge: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w400, + letterSpacing: 0.5, + ), + + /// 14 sp — Standard body text (most common). + /// Use for: secondary descriptions, ListTile subtitles, form helper text, + /// dialog body copy. This is the workhorse style. + bodyMedium: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w400, + letterSpacing: 0.25, + ), + + /// 12 sp — Small body / caption text. + /// Use for: captions under images, timestamps, metadata, fine print, + /// secondary info below a ListTile. + bodySmall: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w400, + letterSpacing: 0.4, + ), + + // ── Label ───────────────────────────────────────────────────────────────── + // Use for UI control labels, buttons, and navigation items. + // NOT for body copy — these are designed to label interactive elements. + + /// 14 sp — Button and prominent control label. + /// Use for: ElevatedButton / TextButton / OutlinedButton text, + /// navigation bar labels (selected), tab bar labels. + labelLarge: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + letterSpacing: 0.1, + ), + + /// 12 sp — Standard control label. + /// Use for: chip text, badge text, tooltip text, navigation rail labels, + /// input counter text, overline headings. + labelMedium: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + letterSpacing: 0.5, + ), + + /// 11 sp — Smallest control label. + /// Use for: dense navigation labels, very small badges, annotation text, + /// data table column headers. Avoid for reading-heavy content. + labelSmall: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + letterSpacing: 0.5, + ), + ); + + {{#if flags.hasCustomFonts}} + // Apply the primary custom font family to every text style in the scale. + return baseTextTheme.apply(fontFamily: '{{flags.primaryFontFamily}}'); + {{else}} + return baseTextTheme; + {{/if}} +} \ No newline at end of file diff --git a/cli/templates/base/lib/src/theme/theme.dart.hbs b/cli/templates/base/lib/src/theme/theme.dart.hbs new file mode 100644 index 0000000..a8810b3 --- /dev/null +++ b/cli/templates/base/lib/src/theme/theme.dart.hbs @@ -0,0 +1,431 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart'; + +import 'text_theme.dart'; +import 'color_schemes.dart'; + +Color _colorFromHex(String hex) { + final cleaned = hex.replaceFirst('#', ''); + return Color(int.parse('ff$cleaned', radix: 16)); +} + +/// Custom theme extension for spacing and other design tokens +class AppDesignTokens extends ThemeExtension { + const AppDesignTokens({ + required this.paddingSmall, + required this.paddingMedium, + required this.paddingLarge, + required this.borderRadiusSmall, + required this.borderRadiusMedium, + required this.borderRadiusLarge, + required this.cardElevation, + }); + + final double paddingSmall; + final double paddingMedium; + final double paddingLarge; + final double borderRadiusSmall; + final double borderRadiusMedium; + final double borderRadiusLarge; + final double cardElevation; + + static const fallback = AppDesignTokens( + paddingSmall: 8, + paddingMedium: 16, + paddingLarge: 24, + borderRadiusSmall: 4, + borderRadiusMedium: 12, + borderRadiusLarge: 24, + cardElevation: 0, + ); + + @override + ThemeExtension copyWith({ + double? paddingSmall, + double? paddingMedium, + double? paddingLarge, + double? borderRadiusSmall, + double? borderRadiusMedium, + double? borderRadiusLarge, + double? cardElevation, + }) { + return AppDesignTokens( + paddingSmall: paddingSmall ?? this.paddingSmall, + paddingMedium: paddingMedium ?? this.paddingMedium, + paddingLarge: paddingLarge ?? this.paddingLarge, + borderRadiusSmall: borderRadiusSmall ?? this.borderRadiusSmall, + borderRadiusMedium: borderRadiusMedium ?? this.borderRadiusMedium, + borderRadiusLarge: borderRadiusLarge ?? this.borderRadiusLarge, + cardElevation: cardElevation ?? this.cardElevation, + ); + } + + @override + ThemeExtension lerp( + covariant ThemeExtension? other, + double t, + ) { + if (other is! AppDesignTokens) return this; + return AppDesignTokens( + paddingSmall: lerpDouble(paddingSmall, other.paddingSmall, t)!, + paddingMedium: lerpDouble(paddingMedium, other.paddingMedium, t)!, + paddingLarge: lerpDouble(paddingLarge, other.paddingLarge, t)!, + borderRadiusSmall: lerpDouble(borderRadiusSmall, other.borderRadiusSmall, t)!, + borderRadiusMedium: lerpDouble(borderRadiusMedium, other.borderRadiusMedium, t)!, + borderRadiusLarge: lerpDouble(borderRadiusLarge, other.borderRadiusLarge, t)!, + cardElevation: lerpDouble(cardElevation, other.cardElevation, t)!, + ); + } + + static double? lerpDouble(double? a, double? b, double t) { + if (a == null && b == null) return null; + a ??= 0.0; + b ??= 0.0; + return a + (b - a) * t; + } +} + +ThemeData _buildTheme(ColorScheme colorScheme, AppColorsExtension customColors) { + final textTheme = buildTextTheme(); + + return ThemeData( + useMaterial3: true, + primaryColor: colorScheme.primary, + colorScheme: colorScheme, + textTheme: textTheme, + {{#if flags.hasCustomFonts}} + fontFamily: '{{flags.primaryFontFamily}}', + {{/if}} + extensions: [ + customColors, + AppDesignTokens.fallback, + ], + + // --- Basic Elements --- + scaffoldBackgroundColor: colorScheme.surface, + dividerTheme: DividerThemeData( + color: colorScheme.outlineVariant, + thickness: 1, + space: 1, + ), + iconTheme: IconThemeData( + color: colorScheme.onSurface, + size: 24, + ), + + // --- Widget Themes --- + + // App Bar Theme + appBarTheme: AppBarTheme( + backgroundColor: colorScheme.surface, + foregroundColor: colorScheme.onSurface, + elevation: 0, + centerTitle: true, + titleTextStyle: textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), + ), + + // Button Themes + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: colorScheme.primary, + foregroundColor: colorScheme.onPrimary, + minimumSize: const Size(88, 48), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + elevation: 0, + ).copyWith( + elevation: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.hovered)) return 2; + return 0; + }), + ), + ), + + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + minimumSize: const Size(88, 48), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + minimumSize: const Size(88, 48), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + side: BorderSide(color: colorScheme.outline, width: 1.5), + ), + ), + + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + minimumSize: const Size(88, 40), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + ), + + // Card Theme + cardTheme: CardThemeData( + clipBehavior: Clip.antiAlias, + elevation: AppDesignTokens.fallback.cardElevation, + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder( + side: BorderSide(color: colorScheme.outlineVariant, width: 1), + borderRadius: BorderRadius.circular(16), + ), + color: colorScheme.surfaceContainerLow, + ), + + // Input Decoration Theme + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + + borderSide: BorderSide(color: colorScheme.outline), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: colorScheme.outline), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: colorScheme.primary, width: 2), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: colorScheme.error), + ), + floatingLabelStyle: TextStyle(color: colorScheme.primary), + labelStyle: textTheme.labelMedium?.copyWith(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5)), + hintStyle: textTheme.labelMedium?.copyWith(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5)), + ), + + // Navigation Bar Theme + navigationBarTheme: NavigationBarThemeData( + backgroundColor: colorScheme.surface, + indicatorColor: colorScheme.secondaryContainer, + elevation: 8, + labelBehavior: NavigationDestinationLabelBehavior.alwaysShow, + height: 80, + labelTextStyle: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + return textTheme.labelSmall?.copyWith(color: colorScheme.primary, fontWeight: FontWeight.bold); + } + return textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant); + }), + ), + + // Navigation Rail Theme + navigationRailTheme: NavigationRailThemeData( + backgroundColor: colorScheme.surface, + indicatorColor: colorScheme.secondaryContainer, + labelType: NavigationRailLabelType.all, + unselectedLabelTextStyle: textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant), + selectedLabelTextStyle: textTheme.labelSmall?.copyWith(color: colorScheme.primary, fontWeight: FontWeight.bold), + ), + + // Tab Bar Theme + tabBarTheme: TabBarThemeData( + labelColor: colorScheme.primary, + unselectedLabelColor: colorScheme.onSurfaceVariant, + indicatorColor: colorScheme.primary, + indicatorSize: TabBarIndicatorSize.label, + labelStyle: textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold), + unselectedLabelStyle: textTheme.titleSmall, + ), + + // Floating Action Button Theme + floatingActionButtonTheme: FloatingActionButtonThemeData( + backgroundColor: colorScheme.primary, + foregroundColor: colorScheme.onPrimaryContainer, + elevation: 2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + ), + + // Chip Theme + chipTheme: ChipThemeData( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + side: BorderSide(color: colorScheme.outlineVariant), + backgroundColor: colorScheme.surfaceContainerLow, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + labelStyle: textTheme.labelMedium, + ), + + // List Tile Theme + listTileTheme: ListTileThemeData( + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + visualDensity: VisualDensity.comfortable, + titleTextStyle: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600), + subtitleTextStyle: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), + ), + + // Checkbox Theme + checkboxTheme: CheckboxThemeData( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), + ), + + // Switch Theme + switchTheme: SwitchThemeData( + thumbColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) return colorScheme.primary; + return colorScheme.outline; + }), + trackColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) return colorScheme.primaryContainer; + return colorScheme.surfaceContainerHighest; + }), + ), + + // SnackBar Theme + snackBarTheme: SnackBarThemeData( + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + elevation: 4, + backgroundColor: colorScheme.inverseSurface, + contentTextStyle: textTheme.bodyMedium?.copyWith(color: colorScheme.onInverseSurface), + ), + + // Dialog Theme + dialogTheme: DialogThemeData( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + elevation: 0, + backgroundColor: colorScheme.surface, + titleTextStyle: textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + contentTextStyle: textTheme.bodyMedium, + ), + + // Bottom Sheet Theme + bottomSheetTheme: const BottomSheetThemeData( + showDragHandle: true, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(28), + ), + ), + ), + + // Search Bar Theme + searchBarTheme: SearchBarThemeData( + elevation: WidgetStateProperty.all(0), + backgroundColor: WidgetStateProperty.all(colorScheme.surfaceContainerLow), + shape: WidgetStateProperty.all(RoundedRectangleBorder(borderRadius: BorderRadius.circular(28))), + padding: WidgetStateProperty.all(const EdgeInsets.symmetric(horizontal: 16)), + hintStyle: WidgetStateProperty.all(textTheme.bodyLarge?.copyWith(color: colorScheme.onSurfaceVariant)), + ), + + // Badge Theme + badgeTheme: BadgeThemeData( + backgroundColor: colorScheme.error, + textColor: colorScheme.onError, + textStyle: textTheme.labelSmall?.copyWith(fontWeight: FontWeight.bold), + ), + + // Segmented Button Theme + segmentedButtonTheme: SegmentedButtonThemeData( + style: SegmentedButton.styleFrom( + selectedBackgroundColor: colorScheme.secondaryContainer, + selectedForegroundColor: colorScheme.onSecondaryContainer, + side: BorderSide(color: colorScheme.outline), + ), + ), + + // Tooltip Theme + tooltipTheme: TooltipThemeData( + decoration: BoxDecoration( + color: colorScheme.inverseSurface.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(8), + ), + textStyle: textTheme.labelSmall?.copyWith(color: colorScheme.onInverseSurface), + ), + ); +} + +ThemeData buildLightTheme({required String primaryColorHex}) { + final seed = _colorFromHex(primaryColorHex.isNotEmpty ? primaryColorHex : '#6750A4'); + final colorScheme = ColorScheme.fromSeed( + seedColor: seed, + brightness: Brightness.light, + ); + return _buildTheme(colorScheme, AppPalettes.light); +} + +ThemeData buildDarkTheme({required String primaryColorHex}) { + final seed = _colorFromHex(primaryColorHex.isNotEmpty ? primaryColorHex : '#6750A4'); + final colorScheme = ColorScheme.fromSeed( + seedColor: seed, + brightness: Brightness.dark, + ); + return _buildTheme(colorScheme, AppPalettes.dark); +} + +CupertinoThemeData buildCupertinoTheme({required String primaryColorHex}) { + final seed = _colorFromHex(primaryColorHex.isNotEmpty ? primaryColorHex : '#007AFF'); + + return CupertinoThemeData( + applyThemeToAll: true, + primaryColor: seed, + primaryContrastingColor: CupertinoColors.white, + {{#if flags.hasDarkMode}} + {{#if theme.darkMode.system}} + brightness: null, // Allow system-wide dark mode support + {{else}} + brightness: Brightness.dark, + {{/if}} + {{else}} + brightness: Brightness.light, + {{/if}} + scaffoldBackgroundColor: CupertinoColors.systemBackground, + barBackgroundColor: CupertinoColors.systemGrey6, + textTheme: CupertinoTextThemeData( + primaryColor: seed, + textStyle: const TextStyle( + {{#if flags.hasCustomFonts}}fontFamily: '{{flags.primaryFontFamily}}',{{/if}} + fontSize: 17, + letterSpacing: -0.41, + ), + actionTextStyle: TextStyle( + {{#if flags.hasCustomFonts}}fontFamily: '{{flags.primaryFontFamily}}',{{/if}} + color: seed, + fontSize: 17, + fontWeight: FontWeight.w400, + ), + navTitleTextStyle: const TextStyle( + {{#if flags.hasCustomFonts}}fontFamily: '{{flags.primaryFontFamily}}',{{/if}} + fontWeight: FontWeight.w600, + fontSize: 17, + letterSpacing: -0.41, + ), + navLargeTitleTextStyle: const TextStyle( + {{#if flags.hasCustomFonts}}fontFamily: '{{flags.primaryFontFamily}}',{{/if}} + fontWeight: FontWeight.bold, + fontSize: 34, + letterSpacing: 0.41, + ), + tabLabelTextStyle: const TextStyle( + {{#if flags.hasCustomFonts}}fontFamily: '{{flags.primaryFontFamily}}',{{/if}} + fontSize: 10, + fontWeight: FontWeight.w500, + letterSpacing: -0.24, + ), + pickerTextStyle: const TextStyle( + {{#if flags.hasCustomFonts}}fontFamily: '{{flags.primaryFontFamily}}',{{/if}} + fontSize: 21, + letterSpacing: -0.41, + ), + dateTimePickerTextStyle: const TextStyle( + {{#if flags.hasCustomFonts}}fontFamily: '{{flags.primaryFontFamily}}',{{/if}} + fontSize: 21, + letterSpacing: -0.41, + ), + ), + ); +} + diff --git a/cli/templates/base/lib/src/theme/theme_constants.dart.hbs b/cli/templates/base/lib/src/theme/theme_constants.dart.hbs new file mode 100644 index 0000000..30fd80d --- /dev/null +++ b/cli/templates/base/lib/src/theme/theme_constants.dart.hbs @@ -0,0 +1,20 @@ +/// Barrel export for all theme constants. +/// +/// Import this single file to access all design foundation values: +/// ```dart +/// import 'package:{{appSlug}}/src/theme/theme_constants.dart'; +/// +/// SizedBox(height: AppSpacing.md) +/// Container(decoration: BoxDecoration(borderRadius: AppBorders.card)) +/// AnimatedContainer(duration: AppDurations.normal, curve: AppCurves.standard) +/// ``` +library; + +export 'app_spacing.dart'; +export 'app_borders.dart'; +export 'app_shadows.dart'; +export 'app_durations.dart'; +export 'app_curves.dart'; +export 'color_schemes.dart'; +export 'text_theme.dart'; +export 'theme.dart'; diff --git a/cli/templates/base/lib/src/utils/app_utils.dart.hbs b/cli/templates/base/lib/src/utils/app_utils.dart.hbs new file mode 100644 index 0000000..0848f86 --- /dev/null +++ b/cli/templates/base/lib/src/utils/app_utils.dart.hbs @@ -0,0 +1,46 @@ +class AppUtils { + /// Checks if data is null. + static bool isNull(dynamic value) => value == null; + + /// Checks if data is null or blank (empty or only contains whitespace). + static bool isBlank(dynamic value) { + if (value == null) return true; + if (value is String) { + return value.trim().isEmpty; + } + if (value is Iterable || value is Map) { + return value.isEmpty; + } + return false; + } + + /// Uppercase first letter inside string and let the others lowercase. + /// Example: your name => Your name + static String? capitalizeFirst(String s) { + if (isBlank(s)) return s; + return s[0].toUpperCase() + s.substring(1).toLowerCase(); + } + + /// Checks if string is phone number. + static bool isPhoneNumber(String s) { + if (s.length > 16 || s.length < 9) return false; + return hasMatch(s, r'^[+]*[(]{0,1}[0-9]{1,4}[)]{0,1}[-\s\./0-9]*$'); + } + + static bool hasMatch(String? value, String pattern) { + return (value == null) ? false : RegExp(pattern).hasMatch(value); + } + + static bool isValidEmail(String s) { + final emailRegExp = RegExp( + r"^[a-zA-Z0-9.a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]+\.[a-zA-Z]+", + ); + return emailRegExp.hasMatch(s); + } + + /// Checks if string is URL. + static bool isURL(String s) => hasMatch( + s, + r"^((((H|h)(T|t)|(F|f))(T|t)(P|p)((S|s)?))\\://)?(www.|[a-zA-Z0-9].)[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,6}(\:[0-9]{1,5})*(/($|[a-zA-Z0-9\.\,\;\?\'\\\+&%\$#\=~_\-]+))*$", + ); +} diff --git a/cli/templates/base/lib/src/utils/debouncer.dart.hbs b/cli/templates/base/lib/src/utils/debouncer.dart.hbs new file mode 100644 index 0000000..b009b8d --- /dev/null +++ b/cli/templates/base/lib/src/utils/debouncer.dart.hbs @@ -0,0 +1,31 @@ +import 'dart:async'; + +class Debouncer { + final Duration delay; + Timer? _timer; + + Debouncer({this.delay = const Duration(milliseconds: 500)}); + + void run(void Function() action) { + _timer?.cancel(); + _timer = Timer(delay, action); + } + + void dispose() { + _timer?.cancel(); + } +} + +class Throttler { + final Duration delay; + bool _isThrottling = false; + + Throttler({this.delay = const Duration(milliseconds: 500)}); + + void run(void Function() action) { + if (_isThrottling) return; + action(); + _isThrottling = true; + Timer(delay, () => _isThrottling = false); + } +} diff --git a/cli/templates/base/lib/src/utils/error_handler.dart.hbs b/cli/templates/base/lib/src/utils/error_handler.dart.hbs new file mode 100644 index 0000000..7bc2eaf --- /dev/null +++ b/cli/templates/base/lib/src/utils/error_handler.dart.hbs @@ -0,0 +1,14 @@ +class AppErrorHandler { + static String format(dynamic error) { + if (error is String) return error; + + // Add logic for common exceptions (e.g., DioException if using Dio) + // For now, basic error message extraction + try { + if (error?.message != null) return error.message; + if (error?.toString() != null) return error.toString(); + } catch (_) {} + + return 'An unexpected error occurred'; + } +} diff --git a/cli/templates/base/lib/src/utils/failure.dart.hbs b/cli/templates/base/lib/src/utils/failure.dart.hbs new file mode 100644 index 0000000..df5429a --- /dev/null +++ b/cli/templates/base/lib/src/utils/failure.dart.hbs @@ -0,0 +1,30 @@ +import 'package:equatable/equatable.dart'; + +abstract class Failure extends Equatable { + final String message; + final dynamic error; + + const Failure(this.message, {this.error}); + + @override + List get props => [message, error]; + + @override + String toString() => message; +} + +class ServerFailure extends Failure { + const ServerFailure(super.message, {super.error}); +} + +class CacheFailure extends Failure { + const CacheFailure(super.message, {super.error}); +} + +class NetworkFailure extends Failure { + const NetworkFailure(super.message, {super.error}); +} + +class UnknownFailure extends Failure { + const UnknownFailure(super.message, {super.error}); +} diff --git a/cli/templates/base/lib/src/utils/input_formatters.dart.hbs b/cli/templates/base/lib/src/utils/input_formatters.dart.hbs new file mode 100644 index 0000000..0a16883 --- /dev/null +++ b/cli/templates/base/lib/src/utils/input_formatters.dart.hbs @@ -0,0 +1,27 @@ +import 'package:flutter/services.dart'; + +class FormCardFormatter extends TextInputFormatter { + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + final text = newValue.text; + if (newValue.selection.baseOffset == 0) return newValue; + + final buffer = StringBuffer(); + for (int i = 0; i < text.length; i++) { + buffer.write(text[i]); + final nonZeroIndex = i + 1; + if (nonZeroIndex % 4 == 0 && nonZeroIndex != text.length) { + buffer.write(' '); // Add space every 4 digits + } + } + + final string = buffer.toString(); + return newValue.copyWith( + text: string, + selection: TextSelection.collapsed(offset: string.length), + ); + } +} diff --git a/cli/templates/base/lib/src/utils/logger.dart.hbs b/cli/templates/base/lib/src/utils/logger.dart.hbs new file mode 100644 index 0000000..3eece1e --- /dev/null +++ b/cli/templates/base/lib/src/utils/logger.dart.hbs @@ -0,0 +1,37 @@ +import 'dart:developer' as developer; +import 'package:flutter/foundation.dart'; + +class AppLogger { + static void info(String message) { + _log('\x1B[34m$message\x1B[0m', name: 'INFO'); + } + + static void success(String message) { + _log('\x1B[32m$message\x1B[0m', name: 'SUCCESS'); + } + + static void warning(String message) { + _log('\x1B[33m$message\x1B[0m', name: 'WARNING'); + } + + static void error(String message, [Object? error, StackTrace? stackTrace]) { + _log('\x1B[31m$message\x1B[0m', name: 'ERROR', error: error, stackTrace: stackTrace); + } + + static void _log(String message, {String name = '', Object? error, StackTrace? stackTrace}) { + if (kDebugMode) { + developer.log( + message, + name: name, + error: error, + stackTrace: stackTrace, + ); + } + } +} + +// Global helper functions as requested by the user +void logInfo(String msg) => AppLogger.info(msg); +void logSuccess(String msg) => AppLogger.success(msg); +void logWarning(String msg) => AppLogger.warning(msg); +void logError(String msg) => AppLogger.error(msg); diff --git a/cli/templates/base/lib/src/utils/platform_info.dart.hbs b/cli/templates/base/lib/src/utils/platform_info.dart.hbs new file mode 100644 index 0000000..f3a0001 --- /dev/null +++ b/cli/templates/base/lib/src/utils/platform_info.dart.hbs @@ -0,0 +1,13 @@ +import 'package:flutter/foundation.dart'; + +class PlatformInfo { + static bool get isWeb => kIsWeb; + static bool get isAndroid => defaultTargetPlatform == TargetPlatform.android; + static bool get isIOS => defaultTargetPlatform == TargetPlatform.iOS; + static bool get isMacOS => defaultTargetPlatform == TargetPlatform.macOS; + static bool get isWindows => defaultTargetPlatform == TargetPlatform.windows; + static bool get isLinux => defaultTargetPlatform == TargetPlatform.linux; + + static bool get isMobile => isAndroid || isIOS; + static bool get isDesktop => isMacOS || isWindows || isLinux; +} diff --git a/cli/templates/base/lib/src/utils/task_runner.dart.hbs b/cli/templates/base/lib/src/utils/task_runner.dart.hbs new file mode 100644 index 0000000..6230315 --- /dev/null +++ b/cli/templates/base/lib/src/utils/task_runner.dart.hbs @@ -0,0 +1,42 @@ +import 'package:fpdart/fpdart.dart'; + +import '../imports/core_imports.dart'; + +/// A reusable generic function to handle potential exceptions in async tasks +/// and map them to the [Either] type matching [FutureEither]. +/// +/// If [requiresNetwork] is `true` and [isNetworkAvailable] returns `false`, +/// the [action] will not be executed and a [NetworkFailure] will be returned. +FutureEither runTask( + Future Function() action, { + bool requiresNetwork = false, +}) async { + if (requiresNetwork) { + final hasNetwork = await InternetConnectionService().hasConnection(); + + if (!hasNetwork) { + AppLogger.warning('Network unavailable for task'); + showGlobalToast( + message: + 'No internet connection. Please check your connection and try again.', + status: 'warning', + ); + return left( + const NetworkFailure( + 'No internet connection. Please check your connection and try again.', + ), + ); + } + } + + try { + final result = await action(); + return right(result); + } catch (error, stackTrace) { + AppLogger.error('Task execution failed $error', [error, stackTrace]); + final errorMessage = AppErrorHandler.format(error); + + // Depending on logic, map error strings/types to specific Failure variants + return left(ServerFailure(errorMessage, error: error)); + } +} diff --git a/cli/templates/base/lib/src/utils/typedefs.dart.hbs b/cli/templates/base/lib/src/utils/typedefs.dart.hbs new file mode 100644 index 0000000..42fd2df --- /dev/null +++ b/cli/templates/base/lib/src/utils/typedefs.dart.hbs @@ -0,0 +1,6 @@ +import 'package:fpdart/fpdart.dart'; +import 'failure.dart'; + +typedef FutureEither = Future>; +typedef FutureEitherVoid = FutureEither; +typedef StreamEither = Stream>; diff --git a/cli/templates/base/lib/src/utils/utils.dart.hbs b/cli/templates/base/lib/src/utils/utils.dart.hbs new file mode 100644 index 0000000..ee8661e --- /dev/null +++ b/cli/templates/base/lib/src/utils/utils.dart.hbs @@ -0,0 +1,9 @@ +export 'app_utils.dart'; +export 'debouncer.dart'; +export 'error_handler.dart'; +export 'failure.dart'; +export 'input_formatters.dart'; +export 'logger.dart'; +export 'platform_info.dart'; +export 'task_runner.dart'; +export 'typedefs.dart'; diff --git a/cli/templates/base/pubspec.yaml.hbs b/cli/templates/base/pubspec.yaml.hbs new file mode 100644 index 0000000..8bff966 --- /dev/null +++ b/cli/templates/base/pubspec.yaml.hbs @@ -0,0 +1,256 @@ +name: {{flags.appSnake}} +description: {{description}} +publish_to: "none" +version: 1.0.0+1 + +environment: + sdk: ">=3.5.0 <4.0.0" + +dependencies: + flutter: + sdk: flutter + cupertino_icons: ^1.0.8 + + # Navigation + {{#if (eq flags.routerPackage "go_router")}} + go_router: ^17.1.0 + {{else if (eq flags.routerPackage "auto_route")}} + auto_route: ^11.1.0 + {{else if (eq flags.routerPackage "getx")}} + get: ^4.7.3 + {{/if}} + + # Networking + internet_connection_checker_plus: ^2.9.1+2 + {{#if flags.usesDio}} + dio: ^5.9.2 + {{/if}} + {{#if flags.usesHttp}} + http: ^1.6.0 + {{/if}} + + # State Management + fpdart: ^1.2.0 + equatable: ^2.0.7 + {{#if flags.usesFlutterHooks}} + flutter_hooks: ^0.21.3+1 + {{/if}} + {{#if flags.isRiverpod}} + flutter_riverpod: ^3.2.1 + {{#if flags.usesFlutterHooks}} + hooks_riverpod: ^3.2.1 + {{/if}} + {{/if}} + {{#if flags.isBloc}} + flutter_bloc: ^9.1.1 + {{/if}} + {{#if flags.isGetX}} + get: ^4.7.3 + {{/if}} + {{#if flags.isProvider}} + provider: ^6.1.5+1 + {{/if}} + {{#if flags.isMobX}} + mobx: ^2.6.0 + flutter_mobx: ^2.3.0 + provider: ^6.1.5+1 + {{/if}} + + # Backend – Firebase + {{#if (eq backend.provider "firebase")}} + firebase_core: ^4.5.0 + {{#if backend.options.authEmail}} + firebase_auth: ^6.2.0 + {{/if}} + {{#if backend.options.firestore}} + cloud_firestore: ^6.1.3 + {{/if}} + {{#if backend.options.realtimeDb}} + firebase_database: ^12.1.4 + {{/if}} + {{#if backend.options.storage}} + firebase_storage: ^13.1.0 + {{/if}} + {{#if backend.options.analytics}} + firebase_analytics: ^12.1.3 + {{/if}} + {{#if backend.options.crashlytics}} + firebase_crashlytics: ^5.0.8 + {{/if}} + {{/if}} + + # Backend – Supabase + {{#if (eq backend.provider "supabase")}} + supabase_flutter: ^2.12.0 + {{/if}} + + # Backend – Appwrite + {{#if (eq backend.provider "appwrite")}} + appwrite: ^22.0.0 + {{/if}} + + # Storage + {{#if flags.usesHive}} + hive_ce: ^2.19.3 + hive_ce_flutter: ^2.3.4 + {{/if}} + {{#if flags.usesSharedPreferences}} + shared_preferences: ^2.5.4 + {{/if}} + {{#if flags.usesSecureStorage}} + flutter_secure_storage: ^10.0.0 + {{/if}} + + # Images & SVG + {{#if flags.usesCachedNetworkImage}} + cached_network_image: ^3.4.1 + {{/if}} + {{#if flags.usesFlutterSvg}} + flutter_svg: ^2.2.4 + {{/if}} + + # Icons + {{#if flags.usesIconsaxPlus}} + iconsax_plus: ^1.0.0 + {{/if}} + {{#if flags.usesFlutterRemix}} + flutter_remix: ^0.0.3 + {{/if}} + {{#if flags.usesHugeicons}} + hugeicons: ^1.1.5 + {{/if}} + + # Shimmer / Skeleton + {{#if flags.usesSkeletonizer}} + skeletonizer: ^2.1.3 + {{/if}} + + # Responsive UI + {{#if flags.usesScreenutil}} + flutter_screenutil: ^5.9.3 + {{/if}} + + # Animations + flutter_animate: ^4.5.2 + smooth_page_indicator: ^2.0.1 + + # Environment + {{#if flags.usesDotenv}} + flutter_dotenv: ^6.0.0 + {{/if}} + + # Logging + {{#if flags.usesLogger}} + logger: ^2.6.2 + {{/if}} + + # Media + {{#if flags.usesImagePicker}} + image_picker: ^1.2.1 + {{/if}} + {{#if flags.usesCamera}} + camera: ^0.10.5+9 + {{/if}} + {{#if flags.usesFilePicker}} + file_picker: ^10.3.10 + {{/if}} + + # Notifications + {{#if flags.usesNotifications}} + flutter_local_notifications: ^17.2.2 + {{/if}} + + # Utilities + {{#if flags.usesPathProvider}} + path_provider: ^2.1.4 + {{/if}} + {{#if flags.usesUrlLauncher}} + url_launcher: ^6.3.1 + {{/if}} + {{#if flags.usesSharePlus}} + share_plus: ^10.1.4 + {{/if}} + {{#if flags.usesPermissionHandler}} + permission_handler: ^12.0.1 + {{/if}} + {{#if flags.usesGeolocator}} + geolocator: ^14.0.2 + {{/if}} + + # Device & System + {{#if flags.usesDeviceInfoPlus}} + device_info_plus: ^11.2.0 + {{/if}} + {{#if flags.usesAppVersionUpdate}} + app_version_update: ^6.2.0 + {{/if}} + + + # Localization + {{#if flags.supportsLocalization}} + easy_localization: ^3.0.8 + {{/if}} + + {{#if flags.usesFlutterNativeSplash}} + flutter_native_splash: ^2.4.7 + {{/if}} + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + + # Code Generation + {{#if (or flags.isMobX flags.usesHive (eq flags.routerPackage "auto_route"))}} + build_runner: ^2.12.2 + {{/if}} + {{#if flags.usesHive}} + hive_ce_generator: ^1.11.1 + {{/if}} + {{#if flags.isMobX}} + mobx_codegen: ^2.7.6 + {{/if}} + {{#if (eq flags.routerPackage "auto_route")}} + auto_route_generator: ^10.5.0 +{{/if}} + +dependency_overrides: + {{#if flags.isRiverpod}} + # Riverpod 3.x requires a newer test_api than what Flutter SDK pins. + # This override allows resolution while remaining compatible with flutter_test. + test_api: 0.7.6 + {{/if}} + +flutter: + uses-material-design: true + + assets: + - assets/ + - assets/images/ + - assets/icons/ + {{#if flags.supportsLocalization}} + - assets/translations/ + {{/if}} + {{#if flags.hasCustomFonts}} + - assets/fonts/ + {{/if}} + {{#if flags.usesDotenv}} + - .env + {{/if}} + + {{#if flags.hasCustomFonts}} + fonts: + {{#each flags.fontFamilies}} + - family: {{this.family}} + fonts: + {{#each this.fonts}} + - asset: assets/fonts/{{this.fileName}} + {{#if (eq this.style "italic")}} + style: italic + {{/if}} + {{#unless (eq this.weight "400")}} + weight: {{this.weight}} + {{/unless}} + {{/each}} + {{/each}} + {{/if}} diff --git a/cli/templates/base/test/widget_test.dart.hbs b/cli/templates/base/test/widget_test.dart.hbs new file mode 100644 index 0000000..485482a --- /dev/null +++ b/cli/templates/base/test/widget_test.dart.hbs @@ -0,0 +1,56 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/material.dart'; +{{#if flags.isRiverpod}} +import 'package:flutter_riverpod/flutter_riverpod.dart'; +{{/if}} + +import 'package:{{snakeCase flags.appSlug}}/src/app.dart'; +{{#if flags.supportsLocalization}} +import 'package:easy_localization/easy_localization.dart'; +{{#if flags.usesSharedPreferences}} +import 'package:shared_preferences/shared_preferences.dart'; +{{/if}} +{{/if}} + +void main() { + testWidgets('App should build', (WidgetTester tester) async { + // Build our app and trigger a frame. + {{#if flags.supportsLocalization}} + {{#if flags.usesSharedPreferences}} + SharedPreferences.setMockInitialValues({}); + {{/if}} + await EasyLocalization.ensureInitialized(); + {{/if}} + + {{#if flags.isRiverpod}} + await tester.pumpWidget( + {{#if flags.supportsLocalization}} + EasyLocalization( + supportedLocales: const [{{#each flags.supportedLocales}}Locale('{{this}}'),{{/each}}], + path: 'assets/translations', + fallbackLocale: const Locale('{{flags.fallbackLocale}}'), + child: const ProviderScope(child: App()), + ) + {{else}} + const ProviderScope(child: App()) + {{/if}} + ); + {{else}} + await tester.pumpWidget( + {{#if flags.supportsLocalization}} + EasyLocalization( + supportedLocales: const [{{#each flags.supportedLocales}}Locale('{{this}}'),{{/each}}], + path: 'assets/translations', + fallbackLocale: const Locale('{{flags.fallbackLocale}}'), + child: const App(), + ) + {{else}} + const App() + {{/if}} + ); + {{/if}} + + // Verify that our base app builds successfully. + expect(find.byType(App), findsOneWidget); + }); +} diff --git a/cli/templates/overlays/architecture/clean/architecture.md b/cli/templates/overlays/architecture/clean/architecture.md new file mode 100644 index 0000000..e3ee33c --- /dev/null +++ b/cli/templates/overlays/architecture/clean/architecture.md @@ -0,0 +1,6 @@ +# Clean Architecture layout + +- Domain: entities, use cases, repositories (interfaces) +- Data: datasources, repository implementations, mappers +- Presentation: widgets, state management, routing + diff --git a/cli/templates/overlays/architecture/clean/lib/src/features/auth/data/models/user_model.dart.hbs b/cli/templates/overlays/architecture/clean/lib/src/features/auth/data/models/user_model.dart.hbs new file mode 100644 index 0000000..f6405a4 --- /dev/null +++ b/cli/templates/overlays/architecture/clean/lib/src/features/auth/data/models/user_model.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/user_model }} diff --git a/cli/templates/overlays/architecture/clean/lib/src/features/auth/data/repositories/auth_repository_impl.dart.hbs b/cli/templates/overlays/architecture/clean/lib/src/features/auth/data/repositories/auth_repository_impl.dart.hbs new file mode 100644 index 0000000..9c2ef4e --- /dev/null +++ b/cli/templates/overlays/architecture/clean/lib/src/features/auth/data/repositories/auth_repository_impl.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/auth_repository_impl }} diff --git a/cli/templates/overlays/architecture/clean/lib/src/features/auth/domain/entities/user.dart.hbs b/cli/templates/overlays/architecture/clean/lib/src/features/auth/domain/entities/user.dart.hbs new file mode 100644 index 0000000..f6405a4 --- /dev/null +++ b/cli/templates/overlays/architecture/clean/lib/src/features/auth/domain/entities/user.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/user_model }} diff --git a/cli/templates/overlays/architecture/clean/lib/src/features/auth/domain/repositories/auth_repository.dart.hbs b/cli/templates/overlays/architecture/clean/lib/src/features/auth/domain/repositories/auth_repository.dart.hbs new file mode 100644 index 0000000..5c45ab9 --- /dev/null +++ b/cli/templates/overlays/architecture/clean/lib/src/features/auth/domain/repositories/auth_repository.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/auth_repository }} diff --git a/cli/templates/overlays/architecture/clean/lib/src/features/auth/presentation/providers/(isBloc)@auth_bloc.dart.hbs b/cli/templates/overlays/architecture/clean/lib/src/features/auth/presentation/providers/(isBloc)@auth_bloc.dart.hbs new file mode 100644 index 0000000..6a51b20 --- /dev/null +++ b/cli/templates/overlays/architecture/clean/lib/src/features/auth/presentation/providers/(isBloc)@auth_bloc.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/auth_logic }} diff --git a/cli/templates/overlays/architecture/clean/lib/src/features/auth/presentation/providers/(isBloc)@session_bloc.dart.hbs b/cli/templates/overlays/architecture/clean/lib/src/features/auth/presentation/providers/(isBloc)@session_bloc.dart.hbs new file mode 100644 index 0000000..1024699 --- /dev/null +++ b/cli/templates/overlays/architecture/clean/lib/src/features/auth/presentation/providers/(isBloc)@session_bloc.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/session_provider }} diff --git a/cli/templates/overlays/architecture/clean/lib/src/features/auth/presentation/providers/(isGetX)@auth_controller.dart.hbs b/cli/templates/overlays/architecture/clean/lib/src/features/auth/presentation/providers/(isGetX)@auth_controller.dart.hbs new file mode 100644 index 0000000..6a51b20 --- /dev/null +++ b/cli/templates/overlays/architecture/clean/lib/src/features/auth/presentation/providers/(isGetX)@auth_controller.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/auth_logic }} diff --git a/cli/templates/overlays/architecture/clean/lib/src/features/auth/presentation/providers/(isGetX)@session_controller.dart.hbs b/cli/templates/overlays/architecture/clean/lib/src/features/auth/presentation/providers/(isGetX)@session_controller.dart.hbs new file mode 100644 index 0000000..1024699 --- /dev/null +++ b/cli/templates/overlays/architecture/clean/lib/src/features/auth/presentation/providers/(isGetX)@session_controller.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/session_provider }} diff --git a/cli/templates/overlays/architecture/clean/lib/src/features/auth/presentation/providers/(isMobX)@auth_store.dart.hbs b/cli/templates/overlays/architecture/clean/lib/src/features/auth/presentation/providers/(isMobX)@auth_store.dart.hbs new file mode 100644 index 0000000..6a51b20 --- /dev/null +++ b/cli/templates/overlays/architecture/clean/lib/src/features/auth/presentation/providers/(isMobX)@auth_store.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/auth_logic }} diff --git a/cli/templates/overlays/architecture/clean/lib/src/features/auth/presentation/providers/(isMobX)@session_store.dart.hbs b/cli/templates/overlays/architecture/clean/lib/src/features/auth/presentation/providers/(isMobX)@session_store.dart.hbs new file mode 100644 index 0000000..1024699 --- /dev/null +++ b/cli/templates/overlays/architecture/clean/lib/src/features/auth/presentation/providers/(isMobX)@session_store.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/session_provider }} diff --git a/cli/templates/overlays/architecture/clean/lib/src/features/auth/presentation/providers/(isNoneState)@session_manager.dart.hbs b/cli/templates/overlays/architecture/clean/lib/src/features/auth/presentation/providers/(isNoneState)@session_manager.dart.hbs new file mode 100644 index 0000000..1024699 --- /dev/null +++ b/cli/templates/overlays/architecture/clean/lib/src/features/auth/presentation/providers/(isNoneState)@session_manager.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/session_provider }} diff --git a/cli/templates/overlays/architecture/clean/lib/src/features/auth/presentation/providers/(isProvider,isRiverpod)@auth_provider.dart.hbs b/cli/templates/overlays/architecture/clean/lib/src/features/auth/presentation/providers/(isProvider,isRiverpod)@auth_provider.dart.hbs new file mode 100644 index 0000000..6a51b20 --- /dev/null +++ b/cli/templates/overlays/architecture/clean/lib/src/features/auth/presentation/providers/(isProvider,isRiverpod)@auth_provider.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/auth_logic }} diff --git a/cli/templates/overlays/architecture/clean/lib/src/features/auth/presentation/providers/(isProvider,isRiverpod)@session_provider.dart.hbs b/cli/templates/overlays/architecture/clean/lib/src/features/auth/presentation/providers/(isProvider,isRiverpod)@session_provider.dart.hbs new file mode 100644 index 0000000..1024699 --- /dev/null +++ b/cli/templates/overlays/architecture/clean/lib/src/features/auth/presentation/providers/(isProvider,isRiverpod)@session_provider.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/session_provider }} diff --git a/cli/templates/overlays/architecture/clean/lib/src/features/auth/presentation/screens/forgot_password_screen.dart.hbs b/cli/templates/overlays/architecture/clean/lib/src/features/auth/presentation/screens/forgot_password_screen.dart.hbs new file mode 100644 index 0000000..03b56a6 --- /dev/null +++ b/cli/templates/overlays/architecture/clean/lib/src/features/auth/presentation/screens/forgot_password_screen.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/forgot_password_screen }} diff --git a/cli/templates/overlays/architecture/clean/lib/src/features/auth/presentation/screens/login_screen.dart.hbs b/cli/templates/overlays/architecture/clean/lib/src/features/auth/presentation/screens/login_screen.dart.hbs new file mode 100644 index 0000000..70cf684 --- /dev/null +++ b/cli/templates/overlays/architecture/clean/lib/src/features/auth/presentation/screens/login_screen.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/login_screen }} diff --git a/cli/templates/overlays/architecture/clean/lib/src/features/auth/presentation/screens/signup_screen.dart.hbs b/cli/templates/overlays/architecture/clean/lib/src/features/auth/presentation/screens/signup_screen.dart.hbs new file mode 100644 index 0000000..a464a58 --- /dev/null +++ b/cli/templates/overlays/architecture/clean/lib/src/features/auth/presentation/screens/signup_screen.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/signup_screen }} diff --git a/cli/templates/overlays/architecture/clean/lib/src/features/home/presentation/screens/home_page.dart.hbs b/cli/templates/overlays/architecture/clean/lib/src/features/home/presentation/screens/home_page.dart.hbs new file mode 100644 index 0000000..675351e --- /dev/null +++ b/cli/templates/overlays/architecture/clean/lib/src/features/home/presentation/screens/home_page.dart.hbs @@ -0,0 +1 @@ +{{> features/home/home_page }} diff --git a/cli/templates/overlays/architecture/clean/lib/src/features/onboarding/presentation/screens/onboarding_page.dart.hbs b/cli/templates/overlays/architecture/clean/lib/src/features/onboarding/presentation/screens/onboarding_page.dart.hbs new file mode 100644 index 0000000..83e4417 --- /dev/null +++ b/cli/templates/overlays/architecture/clean/lib/src/features/onboarding/presentation/screens/onboarding_page.dart.hbs @@ -0,0 +1 @@ +{{> features/onboarding/onboarding_page }} diff --git a/cli/templates/overlays/architecture/feature-first/architecture.md b/cli/templates/overlays/architecture/feature-first/architecture.md new file mode 100644 index 0000000..739a6c8 --- /dev/null +++ b/cli/templates/overlays/architecture/feature-first/architecture.md @@ -0,0 +1,5 @@ +# Feature-first layout + +- Group by feature under `lib/src/features//` +- Each feature owns its presentation, data, and domain slices + diff --git a/cli/templates/overlays/architecture/feature-first/lib/src/features/auth/data/models/user_model.dart.hbs b/cli/templates/overlays/architecture/feature-first/lib/src/features/auth/data/models/user_model.dart.hbs new file mode 100644 index 0000000..f6405a4 --- /dev/null +++ b/cli/templates/overlays/architecture/feature-first/lib/src/features/auth/data/models/user_model.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/user_model }} diff --git a/cli/templates/overlays/architecture/feature-first/lib/src/features/auth/data/repositories/auth_repository_impl.dart.hbs b/cli/templates/overlays/architecture/feature-first/lib/src/features/auth/data/repositories/auth_repository_impl.dart.hbs new file mode 100644 index 0000000..9c2ef4e --- /dev/null +++ b/cli/templates/overlays/architecture/feature-first/lib/src/features/auth/data/repositories/auth_repository_impl.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/auth_repository_impl }} diff --git a/cli/templates/overlays/architecture/feature-first/lib/src/features/auth/domain/entities/user.dart.hbs b/cli/templates/overlays/architecture/feature-first/lib/src/features/auth/domain/entities/user.dart.hbs new file mode 100644 index 0000000..f6405a4 --- /dev/null +++ b/cli/templates/overlays/architecture/feature-first/lib/src/features/auth/domain/entities/user.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/user_model }} diff --git a/cli/templates/overlays/architecture/feature-first/lib/src/features/auth/domain/repositories/auth_repository.dart.hbs b/cli/templates/overlays/architecture/feature-first/lib/src/features/auth/domain/repositories/auth_repository.dart.hbs new file mode 100644 index 0000000..5c45ab9 --- /dev/null +++ b/cli/templates/overlays/architecture/feature-first/lib/src/features/auth/domain/repositories/auth_repository.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/auth_repository }} diff --git a/cli/templates/overlays/architecture/feature-first/lib/src/features/auth/presentation/providers/(isBloc)@auth_bloc.dart.hbs b/cli/templates/overlays/architecture/feature-first/lib/src/features/auth/presentation/providers/(isBloc)@auth_bloc.dart.hbs new file mode 100644 index 0000000..6a51b20 --- /dev/null +++ b/cli/templates/overlays/architecture/feature-first/lib/src/features/auth/presentation/providers/(isBloc)@auth_bloc.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/auth_logic }} diff --git a/cli/templates/overlays/architecture/feature-first/lib/src/features/auth/presentation/providers/(isBloc)@session_bloc.dart.hbs b/cli/templates/overlays/architecture/feature-first/lib/src/features/auth/presentation/providers/(isBloc)@session_bloc.dart.hbs new file mode 100644 index 0000000..1024699 --- /dev/null +++ b/cli/templates/overlays/architecture/feature-first/lib/src/features/auth/presentation/providers/(isBloc)@session_bloc.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/session_provider }} diff --git a/cli/templates/overlays/architecture/feature-first/lib/src/features/auth/presentation/providers/(isGetX)@auth_controller.dart.hbs b/cli/templates/overlays/architecture/feature-first/lib/src/features/auth/presentation/providers/(isGetX)@auth_controller.dart.hbs new file mode 100644 index 0000000..6a51b20 --- /dev/null +++ b/cli/templates/overlays/architecture/feature-first/lib/src/features/auth/presentation/providers/(isGetX)@auth_controller.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/auth_logic }} diff --git a/cli/templates/overlays/architecture/feature-first/lib/src/features/auth/presentation/providers/(isGetX)@session_controller.dart.hbs b/cli/templates/overlays/architecture/feature-first/lib/src/features/auth/presentation/providers/(isGetX)@session_controller.dart.hbs new file mode 100644 index 0000000..1024699 --- /dev/null +++ b/cli/templates/overlays/architecture/feature-first/lib/src/features/auth/presentation/providers/(isGetX)@session_controller.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/session_provider }} diff --git a/cli/templates/overlays/architecture/feature-first/lib/src/features/auth/presentation/providers/(isMobX)@auth_store.dart.hbs b/cli/templates/overlays/architecture/feature-first/lib/src/features/auth/presentation/providers/(isMobX)@auth_store.dart.hbs new file mode 100644 index 0000000..6a51b20 --- /dev/null +++ b/cli/templates/overlays/architecture/feature-first/lib/src/features/auth/presentation/providers/(isMobX)@auth_store.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/auth_logic }} diff --git a/cli/templates/overlays/architecture/feature-first/lib/src/features/auth/presentation/providers/(isMobX)@session_store.dart.hbs b/cli/templates/overlays/architecture/feature-first/lib/src/features/auth/presentation/providers/(isMobX)@session_store.dart.hbs new file mode 100644 index 0000000..1024699 --- /dev/null +++ b/cli/templates/overlays/architecture/feature-first/lib/src/features/auth/presentation/providers/(isMobX)@session_store.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/session_provider }} diff --git a/cli/templates/overlays/architecture/feature-first/lib/src/features/auth/presentation/providers/(isNoneState)@session_manager.dart.hbs b/cli/templates/overlays/architecture/feature-first/lib/src/features/auth/presentation/providers/(isNoneState)@session_manager.dart.hbs new file mode 100644 index 0000000..1024699 --- /dev/null +++ b/cli/templates/overlays/architecture/feature-first/lib/src/features/auth/presentation/providers/(isNoneState)@session_manager.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/session_provider }} diff --git a/cli/templates/overlays/architecture/feature-first/lib/src/features/auth/presentation/providers/(isProvider,isRiverpod)@auth_provider.dart.hbs b/cli/templates/overlays/architecture/feature-first/lib/src/features/auth/presentation/providers/(isProvider,isRiverpod)@auth_provider.dart.hbs new file mode 100644 index 0000000..6a51b20 --- /dev/null +++ b/cli/templates/overlays/architecture/feature-first/lib/src/features/auth/presentation/providers/(isProvider,isRiverpod)@auth_provider.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/auth_logic }} diff --git a/cli/templates/overlays/architecture/feature-first/lib/src/features/auth/presentation/providers/(isProvider,isRiverpod)@session_provider.dart.hbs b/cli/templates/overlays/architecture/feature-first/lib/src/features/auth/presentation/providers/(isProvider,isRiverpod)@session_provider.dart.hbs new file mode 100644 index 0000000..1024699 --- /dev/null +++ b/cli/templates/overlays/architecture/feature-first/lib/src/features/auth/presentation/providers/(isProvider,isRiverpod)@session_provider.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/session_provider }} diff --git a/cli/templates/overlays/architecture/feature-first/lib/src/features/auth/presentation/screens/forgot_password_screen.dart.hbs b/cli/templates/overlays/architecture/feature-first/lib/src/features/auth/presentation/screens/forgot_password_screen.dart.hbs new file mode 100644 index 0000000..03b56a6 --- /dev/null +++ b/cli/templates/overlays/architecture/feature-first/lib/src/features/auth/presentation/screens/forgot_password_screen.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/forgot_password_screen }} diff --git a/cli/templates/overlays/architecture/feature-first/lib/src/features/auth/presentation/screens/login_screen.dart.hbs b/cli/templates/overlays/architecture/feature-first/lib/src/features/auth/presentation/screens/login_screen.dart.hbs new file mode 100644 index 0000000..70cf684 --- /dev/null +++ b/cli/templates/overlays/architecture/feature-first/lib/src/features/auth/presentation/screens/login_screen.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/login_screen }} diff --git a/cli/templates/overlays/architecture/feature-first/lib/src/features/auth/presentation/screens/signup_screen.dart.hbs b/cli/templates/overlays/architecture/feature-first/lib/src/features/auth/presentation/screens/signup_screen.dart.hbs new file mode 100644 index 0000000..a464a58 --- /dev/null +++ b/cli/templates/overlays/architecture/feature-first/lib/src/features/auth/presentation/screens/signup_screen.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/signup_screen }} diff --git a/cli/templates/overlays/architecture/feature-first/lib/src/features/home/presentation/screens/home_page.dart.hbs b/cli/templates/overlays/architecture/feature-first/lib/src/features/home/presentation/screens/home_page.dart.hbs new file mode 100644 index 0000000..675351e --- /dev/null +++ b/cli/templates/overlays/architecture/feature-first/lib/src/features/home/presentation/screens/home_page.dart.hbs @@ -0,0 +1 @@ +{{> features/home/home_page }} diff --git a/cli/templates/overlays/architecture/feature-first/lib/src/features/onboarding/presentation/screens/onboarding_page.dart.hbs b/cli/templates/overlays/architecture/feature-first/lib/src/features/onboarding/presentation/screens/onboarding_page.dart.hbs new file mode 100644 index 0000000..83e4417 --- /dev/null +++ b/cli/templates/overlays/architecture/feature-first/lib/src/features/onboarding/presentation/screens/onboarding_page.dart.hbs @@ -0,0 +1 @@ +{{> features/onboarding/onboarding_page }} diff --git a/cli/templates/overlays/architecture/layer-first/architecture.md b/cli/templates/overlays/architecture/layer-first/architecture.md new file mode 100644 index 0000000..6c20815 --- /dev/null +++ b/cli/templates/overlays/architecture/layer-first/architecture.md @@ -0,0 +1,6 @@ +# Layer-first layout + +- `lib/src/presentation` for UI & routing +- `lib/src/domain` for models/use cases +- `lib/src/data` for repositories and clients + diff --git a/cli/templates/overlays/architecture/layer-first/lib/src/data/datasources/local/.gitkeep b/cli/templates/overlays/architecture/layer-first/lib/src/data/datasources/local/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/cli/templates/overlays/architecture/layer-first/lib/src/data/datasources/remote/.gitkeep b/cli/templates/overlays/architecture/layer-first/lib/src/data/datasources/remote/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/cli/templates/overlays/architecture/layer-first/lib/src/data/models/user_model.dart.hbs b/cli/templates/overlays/architecture/layer-first/lib/src/data/models/user_model.dart.hbs new file mode 100644 index 0000000..82d16af --- /dev/null +++ b/cli/templates/overlays/architecture/layer-first/lib/src/data/models/user_model.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/user_model }} \ No newline at end of file diff --git a/cli/templates/overlays/architecture/layer-first/lib/src/data/repositories/auth_repository_impl.dart.hbs b/cli/templates/overlays/architecture/layer-first/lib/src/data/repositories/auth_repository_impl.dart.hbs new file mode 100644 index 0000000..9c2ef4e --- /dev/null +++ b/cli/templates/overlays/architecture/layer-first/lib/src/data/repositories/auth_repository_impl.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/auth_repository_impl }} diff --git a/cli/templates/overlays/architecture/layer-first/lib/src/domain/entities/user.dart.hbs b/cli/templates/overlays/architecture/layer-first/lib/src/domain/entities/user.dart.hbs new file mode 100644 index 0000000..82d16af --- /dev/null +++ b/cli/templates/overlays/architecture/layer-first/lib/src/domain/entities/user.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/user_model }} \ No newline at end of file diff --git a/cli/templates/overlays/architecture/layer-first/lib/src/domain/repositories/auth_repository.dart.hbs b/cli/templates/overlays/architecture/layer-first/lib/src/domain/repositories/auth_repository.dart.hbs new file mode 100644 index 0000000..5c45ab9 --- /dev/null +++ b/cli/templates/overlays/architecture/layer-first/lib/src/domain/repositories/auth_repository.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/auth_repository }} diff --git a/cli/templates/overlays/architecture/layer-first/lib/src/domain/usecases/auth/.gitkeep b/cli/templates/overlays/architecture/layer-first/lib/src/domain/usecases/auth/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/cli/templates/overlays/architecture/layer-first/lib/src/presentation/providers/(isBloc)@auth_bloc.dart.hbs b/cli/templates/overlays/architecture/layer-first/lib/src/presentation/providers/(isBloc)@auth_bloc.dart.hbs new file mode 100644 index 0000000..6a51b20 --- /dev/null +++ b/cli/templates/overlays/architecture/layer-first/lib/src/presentation/providers/(isBloc)@auth_bloc.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/auth_logic }} diff --git a/cli/templates/overlays/architecture/layer-first/lib/src/presentation/providers/(isBloc)@session_bloc.dart.hbs b/cli/templates/overlays/architecture/layer-first/lib/src/presentation/providers/(isBloc)@session_bloc.dart.hbs new file mode 100644 index 0000000..1024699 --- /dev/null +++ b/cli/templates/overlays/architecture/layer-first/lib/src/presentation/providers/(isBloc)@session_bloc.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/session_provider }} diff --git a/cli/templates/overlays/architecture/layer-first/lib/src/presentation/providers/(isGetX)@auth_controller.dart.hbs b/cli/templates/overlays/architecture/layer-first/lib/src/presentation/providers/(isGetX)@auth_controller.dart.hbs new file mode 100644 index 0000000..6a51b20 --- /dev/null +++ b/cli/templates/overlays/architecture/layer-first/lib/src/presentation/providers/(isGetX)@auth_controller.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/auth_logic }} diff --git a/cli/templates/overlays/architecture/layer-first/lib/src/presentation/providers/(isGetX)@session_controller.dart.hbs b/cli/templates/overlays/architecture/layer-first/lib/src/presentation/providers/(isGetX)@session_controller.dart.hbs new file mode 100644 index 0000000..1024699 --- /dev/null +++ b/cli/templates/overlays/architecture/layer-first/lib/src/presentation/providers/(isGetX)@session_controller.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/session_provider }} diff --git a/cli/templates/overlays/architecture/layer-first/lib/src/presentation/providers/(isMobX)@auth_store.dart.hbs b/cli/templates/overlays/architecture/layer-first/lib/src/presentation/providers/(isMobX)@auth_store.dart.hbs new file mode 100644 index 0000000..6a51b20 --- /dev/null +++ b/cli/templates/overlays/architecture/layer-first/lib/src/presentation/providers/(isMobX)@auth_store.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/auth_logic }} diff --git a/cli/templates/overlays/architecture/layer-first/lib/src/presentation/providers/(isMobX)@session_store.dart.hbs b/cli/templates/overlays/architecture/layer-first/lib/src/presentation/providers/(isMobX)@session_store.dart.hbs new file mode 100644 index 0000000..1024699 --- /dev/null +++ b/cli/templates/overlays/architecture/layer-first/lib/src/presentation/providers/(isMobX)@session_store.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/session_provider }} diff --git a/cli/templates/overlays/architecture/layer-first/lib/src/presentation/providers/(isNoneState)@auth_view_model.dart.hbs b/cli/templates/overlays/architecture/layer-first/lib/src/presentation/providers/(isNoneState)@auth_view_model.dart.hbs new file mode 100644 index 0000000..6a51b20 --- /dev/null +++ b/cli/templates/overlays/architecture/layer-first/lib/src/presentation/providers/(isNoneState)@auth_view_model.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/auth_logic }} diff --git a/cli/templates/overlays/architecture/layer-first/lib/src/presentation/providers/(isNoneState)@session_manager.dart.hbs b/cli/templates/overlays/architecture/layer-first/lib/src/presentation/providers/(isNoneState)@session_manager.dart.hbs new file mode 100644 index 0000000..1024699 --- /dev/null +++ b/cli/templates/overlays/architecture/layer-first/lib/src/presentation/providers/(isNoneState)@session_manager.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/session_provider }} diff --git a/cli/templates/overlays/architecture/layer-first/lib/src/presentation/providers/(isProvider,isRiverpod)@auth_provider.dart.hbs b/cli/templates/overlays/architecture/layer-first/lib/src/presentation/providers/(isProvider,isRiverpod)@auth_provider.dart.hbs new file mode 100644 index 0000000..6a51b20 --- /dev/null +++ b/cli/templates/overlays/architecture/layer-first/lib/src/presentation/providers/(isProvider,isRiverpod)@auth_provider.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/auth_logic }} diff --git a/cli/templates/overlays/architecture/layer-first/lib/src/presentation/providers/(isProvider,isRiverpod)@session_provider.dart.hbs b/cli/templates/overlays/architecture/layer-first/lib/src/presentation/providers/(isProvider,isRiverpod)@session_provider.dart.hbs new file mode 100644 index 0000000..1024699 --- /dev/null +++ b/cli/templates/overlays/architecture/layer-first/lib/src/presentation/providers/(isProvider,isRiverpod)@session_provider.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/session_provider }} diff --git a/cli/templates/overlays/architecture/layer-first/lib/src/presentation/screens/auth/forgot_password_screen.dart.hbs b/cli/templates/overlays/architecture/layer-first/lib/src/presentation/screens/auth/forgot_password_screen.dart.hbs new file mode 100644 index 0000000..03b56a6 --- /dev/null +++ b/cli/templates/overlays/architecture/layer-first/lib/src/presentation/screens/auth/forgot_password_screen.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/forgot_password_screen }} diff --git a/cli/templates/overlays/architecture/layer-first/lib/src/presentation/screens/auth/login_screen.dart.hbs b/cli/templates/overlays/architecture/layer-first/lib/src/presentation/screens/auth/login_screen.dart.hbs new file mode 100644 index 0000000..70cf684 --- /dev/null +++ b/cli/templates/overlays/architecture/layer-first/lib/src/presentation/screens/auth/login_screen.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/login_screen }} diff --git a/cli/templates/overlays/architecture/layer-first/lib/src/presentation/screens/auth/signup_screen.dart.hbs b/cli/templates/overlays/architecture/layer-first/lib/src/presentation/screens/auth/signup_screen.dart.hbs new file mode 100644 index 0000000..a464a58 --- /dev/null +++ b/cli/templates/overlays/architecture/layer-first/lib/src/presentation/screens/auth/signup_screen.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/signup_screen }} diff --git a/cli/templates/overlays/architecture/layer-first/lib/src/presentation/screens/home/home_page.dart.hbs b/cli/templates/overlays/architecture/layer-first/lib/src/presentation/screens/home/home_page.dart.hbs new file mode 100644 index 0000000..675351e --- /dev/null +++ b/cli/templates/overlays/architecture/layer-first/lib/src/presentation/screens/home/home_page.dart.hbs @@ -0,0 +1 @@ +{{> features/home/home_page }} diff --git a/cli/templates/overlays/architecture/layer-first/lib/src/presentation/screens/onboarding/onboarding_page.dart.hbs b/cli/templates/overlays/architecture/layer-first/lib/src/presentation/screens/onboarding/onboarding_page.dart.hbs new file mode 100644 index 0000000..83e4417 --- /dev/null +++ b/cli/templates/overlays/architecture/layer-first/lib/src/presentation/screens/onboarding/onboarding_page.dart.hbs @@ -0,0 +1 @@ +{{> features/onboarding/onboarding_page }} diff --git a/cli/templates/overlays/architecture/layer-first/lib/src/presentation/widgets/.gitkeep b/cli/templates/overlays/architecture/layer-first/lib/src/presentation/widgets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/cli/templates/overlays/architecture/mvc/architecture.md b/cli/templates/overlays/architecture/mvc/architecture.md new file mode 100644 index 0000000..5a6d877 --- /dev/null +++ b/cli/templates/overlays/architecture/mvc/architecture.md @@ -0,0 +1,5 @@ +# MVC layout + +- Keep features under `lib/src/features//` +- UI in `presentation`, controllers in `controllers`, models in `models` + diff --git a/cli/templates/overlays/architecture/mvc/lib/src/controllers/auth/(isBloc)@auth_bloc.dart.hbs b/cli/templates/overlays/architecture/mvc/lib/src/controllers/auth/(isBloc)@auth_bloc.dart.hbs new file mode 100644 index 0000000..6a51b20 --- /dev/null +++ b/cli/templates/overlays/architecture/mvc/lib/src/controllers/auth/(isBloc)@auth_bloc.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/auth_logic }} diff --git a/cli/templates/overlays/architecture/mvc/lib/src/controllers/auth/(isBloc)@session_bloc.dart.hbs b/cli/templates/overlays/architecture/mvc/lib/src/controllers/auth/(isBloc)@session_bloc.dart.hbs new file mode 100644 index 0000000..1024699 --- /dev/null +++ b/cli/templates/overlays/architecture/mvc/lib/src/controllers/auth/(isBloc)@session_bloc.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/session_provider }} diff --git a/cli/templates/overlays/architecture/mvc/lib/src/controllers/auth/(isGetX)@auth_controller.dart.hbs b/cli/templates/overlays/architecture/mvc/lib/src/controllers/auth/(isGetX)@auth_controller.dart.hbs new file mode 100644 index 0000000..6a51b20 --- /dev/null +++ b/cli/templates/overlays/architecture/mvc/lib/src/controllers/auth/(isGetX)@auth_controller.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/auth_logic }} diff --git a/cli/templates/overlays/architecture/mvc/lib/src/controllers/auth/(isGetX)@session_controller.dart.hbs b/cli/templates/overlays/architecture/mvc/lib/src/controllers/auth/(isGetX)@session_controller.dart.hbs new file mode 100644 index 0000000..1024699 --- /dev/null +++ b/cli/templates/overlays/architecture/mvc/lib/src/controllers/auth/(isGetX)@session_controller.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/session_provider }} diff --git a/cli/templates/overlays/architecture/mvc/lib/src/controllers/auth/(isMobX)@auth_store.dart.hbs b/cli/templates/overlays/architecture/mvc/lib/src/controllers/auth/(isMobX)@auth_store.dart.hbs new file mode 100644 index 0000000..6a51b20 --- /dev/null +++ b/cli/templates/overlays/architecture/mvc/lib/src/controllers/auth/(isMobX)@auth_store.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/auth_logic }} diff --git a/cli/templates/overlays/architecture/mvc/lib/src/controllers/auth/(isMobX)@session_store.dart.hbs b/cli/templates/overlays/architecture/mvc/lib/src/controllers/auth/(isMobX)@session_store.dart.hbs new file mode 100644 index 0000000..1024699 --- /dev/null +++ b/cli/templates/overlays/architecture/mvc/lib/src/controllers/auth/(isMobX)@session_store.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/session_provider }} diff --git a/cli/templates/overlays/architecture/mvc/lib/src/controllers/auth/(isNoneState)@session_manager.dart.hbs b/cli/templates/overlays/architecture/mvc/lib/src/controllers/auth/(isNoneState)@session_manager.dart.hbs new file mode 100644 index 0000000..1024699 --- /dev/null +++ b/cli/templates/overlays/architecture/mvc/lib/src/controllers/auth/(isNoneState)@session_manager.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/session_provider }} diff --git a/cli/templates/overlays/architecture/mvc/lib/src/controllers/auth/(isProvider,isRiverpod)@auth_provider.dart.hbs b/cli/templates/overlays/architecture/mvc/lib/src/controllers/auth/(isProvider,isRiverpod)@auth_provider.dart.hbs new file mode 100644 index 0000000..6a51b20 --- /dev/null +++ b/cli/templates/overlays/architecture/mvc/lib/src/controllers/auth/(isProvider,isRiverpod)@auth_provider.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/auth_logic }} diff --git a/cli/templates/overlays/architecture/mvc/lib/src/controllers/auth/(isProvider,isRiverpod)@session_provider.dart.hbs b/cli/templates/overlays/architecture/mvc/lib/src/controllers/auth/(isProvider,isRiverpod)@session_provider.dart.hbs new file mode 100644 index 0000000..1024699 --- /dev/null +++ b/cli/templates/overlays/architecture/mvc/lib/src/controllers/auth/(isProvider,isRiverpod)@session_provider.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/session_provider }} diff --git a/cli/templates/overlays/architecture/mvc/lib/src/models/user_model.dart.hbs b/cli/templates/overlays/architecture/mvc/lib/src/models/user_model.dart.hbs new file mode 100644 index 0000000..f6405a4 --- /dev/null +++ b/cli/templates/overlays/architecture/mvc/lib/src/models/user_model.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/user_model }} diff --git a/cli/templates/overlays/architecture/mvc/lib/src/services/auth_repository.dart.hbs b/cli/templates/overlays/architecture/mvc/lib/src/services/auth_repository.dart.hbs new file mode 100644 index 0000000..5c45ab9 --- /dev/null +++ b/cli/templates/overlays/architecture/mvc/lib/src/services/auth_repository.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/auth_repository }} diff --git a/cli/templates/overlays/architecture/mvc/lib/src/services/auth_repository_impl.dart.hbs b/cli/templates/overlays/architecture/mvc/lib/src/services/auth_repository_impl.dart.hbs new file mode 100644 index 0000000..9c2ef4e --- /dev/null +++ b/cli/templates/overlays/architecture/mvc/lib/src/services/auth_repository_impl.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/auth_repository_impl }} diff --git a/cli/templates/overlays/architecture/mvc/lib/src/views/auth/forgot_password_screen.dart.hbs b/cli/templates/overlays/architecture/mvc/lib/src/views/auth/forgot_password_screen.dart.hbs new file mode 100644 index 0000000..03b56a6 --- /dev/null +++ b/cli/templates/overlays/architecture/mvc/lib/src/views/auth/forgot_password_screen.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/forgot_password_screen }} diff --git a/cli/templates/overlays/architecture/mvc/lib/src/views/auth/login_screen.dart.hbs b/cli/templates/overlays/architecture/mvc/lib/src/views/auth/login_screen.dart.hbs new file mode 100644 index 0000000..70cf684 --- /dev/null +++ b/cli/templates/overlays/architecture/mvc/lib/src/views/auth/login_screen.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/login_screen }} diff --git a/cli/templates/overlays/architecture/mvc/lib/src/views/auth/signup_screen.dart.hbs b/cli/templates/overlays/architecture/mvc/lib/src/views/auth/signup_screen.dart.hbs new file mode 100644 index 0000000..a464a58 --- /dev/null +++ b/cli/templates/overlays/architecture/mvc/lib/src/views/auth/signup_screen.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/signup_screen }} diff --git a/cli/templates/overlays/architecture/mvc/lib/src/views/home/home_page.dart.hbs b/cli/templates/overlays/architecture/mvc/lib/src/views/home/home_page.dart.hbs new file mode 100644 index 0000000..675351e --- /dev/null +++ b/cli/templates/overlays/architecture/mvc/lib/src/views/home/home_page.dart.hbs @@ -0,0 +1 @@ +{{> features/home/home_page }} diff --git a/cli/templates/overlays/architecture/mvc/lib/src/views/onboarding/onboarding_page.dart.hbs b/cli/templates/overlays/architecture/mvc/lib/src/views/onboarding/onboarding_page.dart.hbs new file mode 100644 index 0000000..83e4417 --- /dev/null +++ b/cli/templates/overlays/architecture/mvc/lib/src/views/onboarding/onboarding_page.dart.hbs @@ -0,0 +1 @@ +{{> features/onboarding/onboarding_page }} diff --git a/cli/templates/overlays/architecture/mvvm/architecture.md b/cli/templates/overlays/architecture/mvvm/architecture.md new file mode 100644 index 0000000..3b68b63 --- /dev/null +++ b/cli/templates/overlays/architecture/mvvm/architecture.md @@ -0,0 +1,6 @@ +# MVVM layout + +- View widgets in `presentation/views` +- ViewModels in `presentation/view_models` +- Models/entities in `domain/models` + diff --git a/cli/templates/overlays/architecture/mvvm/lib/src/data/models/user_model.dart.hbs b/cli/templates/overlays/architecture/mvvm/lib/src/data/models/user_model.dart.hbs new file mode 100644 index 0000000..f6405a4 --- /dev/null +++ b/cli/templates/overlays/architecture/mvvm/lib/src/data/models/user_model.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/user_model }} diff --git a/cli/templates/overlays/architecture/mvvm/lib/src/data/repositories/auth_repository.dart.hbs b/cli/templates/overlays/architecture/mvvm/lib/src/data/repositories/auth_repository.dart.hbs new file mode 100644 index 0000000..5c45ab9 --- /dev/null +++ b/cli/templates/overlays/architecture/mvvm/lib/src/data/repositories/auth_repository.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/auth_repository }} diff --git a/cli/templates/overlays/architecture/mvvm/lib/src/data/repositories/auth_repository_impl.dart.hbs b/cli/templates/overlays/architecture/mvvm/lib/src/data/repositories/auth_repository_impl.dart.hbs new file mode 100644 index 0000000..9c2ef4e --- /dev/null +++ b/cli/templates/overlays/architecture/mvvm/lib/src/data/repositories/auth_repository_impl.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/auth_repository_impl }} diff --git a/cli/templates/overlays/architecture/mvvm/lib/src/ui/auth/(isBloc)@bloc/auth_bloc.dart.hbs b/cli/templates/overlays/architecture/mvvm/lib/src/ui/auth/(isBloc)@bloc/auth_bloc.dart.hbs new file mode 100644 index 0000000..6a51b20 --- /dev/null +++ b/cli/templates/overlays/architecture/mvvm/lib/src/ui/auth/(isBloc)@bloc/auth_bloc.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/auth_logic }} diff --git a/cli/templates/overlays/architecture/mvvm/lib/src/ui/auth/(isBloc)@bloc/session_bloc.dart.hbs b/cli/templates/overlays/architecture/mvvm/lib/src/ui/auth/(isBloc)@bloc/session_bloc.dart.hbs new file mode 100644 index 0000000..1024699 --- /dev/null +++ b/cli/templates/overlays/architecture/mvvm/lib/src/ui/auth/(isBloc)@bloc/session_bloc.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/session_provider }} diff --git a/cli/templates/overlays/architecture/mvvm/lib/src/ui/auth/(isGetX)@controllers/auth_controller.dart.hbs b/cli/templates/overlays/architecture/mvvm/lib/src/ui/auth/(isGetX)@controllers/auth_controller.dart.hbs new file mode 100644 index 0000000..6a51b20 --- /dev/null +++ b/cli/templates/overlays/architecture/mvvm/lib/src/ui/auth/(isGetX)@controllers/auth_controller.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/auth_logic }} diff --git a/cli/templates/overlays/architecture/mvvm/lib/src/ui/auth/(isGetX)@controllers/session_controller.dart.hbs b/cli/templates/overlays/architecture/mvvm/lib/src/ui/auth/(isGetX)@controllers/session_controller.dart.hbs new file mode 100644 index 0000000..1024699 --- /dev/null +++ b/cli/templates/overlays/architecture/mvvm/lib/src/ui/auth/(isGetX)@controllers/session_controller.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/session_provider }} diff --git a/cli/templates/overlays/architecture/mvvm/lib/src/ui/auth/(isMobX)@stores/auth_store.dart.hbs b/cli/templates/overlays/architecture/mvvm/lib/src/ui/auth/(isMobX)@stores/auth_store.dart.hbs new file mode 100644 index 0000000..6a51b20 --- /dev/null +++ b/cli/templates/overlays/architecture/mvvm/lib/src/ui/auth/(isMobX)@stores/auth_store.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/auth_logic }} diff --git a/cli/templates/overlays/architecture/mvvm/lib/src/ui/auth/(isMobX)@stores/session_store.dart.hbs b/cli/templates/overlays/architecture/mvvm/lib/src/ui/auth/(isMobX)@stores/session_store.dart.hbs new file mode 100644 index 0000000..1024699 --- /dev/null +++ b/cli/templates/overlays/architecture/mvvm/lib/src/ui/auth/(isMobX)@stores/session_store.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/session_provider }} diff --git a/cli/templates/overlays/architecture/mvvm/lib/src/ui/auth/(isNoneState)@view_models/auth_view_model.dart.hbs b/cli/templates/overlays/architecture/mvvm/lib/src/ui/auth/(isNoneState)@view_models/auth_view_model.dart.hbs new file mode 100644 index 0000000..6a51b20 --- /dev/null +++ b/cli/templates/overlays/architecture/mvvm/lib/src/ui/auth/(isNoneState)@view_models/auth_view_model.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/auth_logic }} diff --git a/cli/templates/overlays/architecture/mvvm/lib/src/ui/auth/(isNoneState)@view_models/session_manager.dart.hbs b/cli/templates/overlays/architecture/mvvm/lib/src/ui/auth/(isNoneState)@view_models/session_manager.dart.hbs new file mode 100644 index 0000000..1024699 --- /dev/null +++ b/cli/templates/overlays/architecture/mvvm/lib/src/ui/auth/(isNoneState)@view_models/session_manager.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/session_provider }} diff --git a/cli/templates/overlays/architecture/mvvm/lib/src/ui/auth/(isProvider,isRiverpod)@providers/auth_provider.dart.hbs b/cli/templates/overlays/architecture/mvvm/lib/src/ui/auth/(isProvider,isRiverpod)@providers/auth_provider.dart.hbs new file mode 100644 index 0000000..6a51b20 --- /dev/null +++ b/cli/templates/overlays/architecture/mvvm/lib/src/ui/auth/(isProvider,isRiverpod)@providers/auth_provider.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/auth_logic }} diff --git a/cli/templates/overlays/architecture/mvvm/lib/src/ui/auth/(isProvider,isRiverpod)@providers/session_provider.dart.hbs b/cli/templates/overlays/architecture/mvvm/lib/src/ui/auth/(isProvider,isRiverpod)@providers/session_provider.dart.hbs new file mode 100644 index 0000000..1024699 --- /dev/null +++ b/cli/templates/overlays/architecture/mvvm/lib/src/ui/auth/(isProvider,isRiverpod)@providers/session_provider.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/session_provider }} diff --git a/cli/templates/overlays/architecture/mvvm/lib/src/ui/auth/forgot_password_screen.dart.hbs b/cli/templates/overlays/architecture/mvvm/lib/src/ui/auth/forgot_password_screen.dart.hbs new file mode 100644 index 0000000..03b56a6 --- /dev/null +++ b/cli/templates/overlays/architecture/mvvm/lib/src/ui/auth/forgot_password_screen.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/forgot_password_screen }} diff --git a/cli/templates/overlays/architecture/mvvm/lib/src/ui/auth/login_screen.dart.hbs b/cli/templates/overlays/architecture/mvvm/lib/src/ui/auth/login_screen.dart.hbs new file mode 100644 index 0000000..70cf684 --- /dev/null +++ b/cli/templates/overlays/architecture/mvvm/lib/src/ui/auth/login_screen.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/login_screen }} diff --git a/cli/templates/overlays/architecture/mvvm/lib/src/ui/auth/signup_screen.dart.hbs b/cli/templates/overlays/architecture/mvvm/lib/src/ui/auth/signup_screen.dart.hbs new file mode 100644 index 0000000..a464a58 --- /dev/null +++ b/cli/templates/overlays/architecture/mvvm/lib/src/ui/auth/signup_screen.dart.hbs @@ -0,0 +1 @@ +{{> features/auth/signup_screen }} diff --git a/cli/templates/overlays/architecture/mvvm/lib/src/ui/home/home_page.dart.hbs b/cli/templates/overlays/architecture/mvvm/lib/src/ui/home/home_page.dart.hbs new file mode 100644 index 0000000..675351e --- /dev/null +++ b/cli/templates/overlays/architecture/mvvm/lib/src/ui/home/home_page.dart.hbs @@ -0,0 +1 @@ +{{> features/home/home_page }} diff --git a/cli/templates/overlays/architecture/mvvm/lib/src/ui/onboarding/onboarding_page.dart.hbs b/cli/templates/overlays/architecture/mvvm/lib/src/ui/onboarding/onboarding_page.dart.hbs new file mode 100644 index 0000000..83e4417 --- /dev/null +++ b/cli/templates/overlays/architecture/mvvm/lib/src/ui/onboarding/onboarding_page.dart.hbs @@ -0,0 +1 @@ +{{> features/onboarding/onboarding_page }} diff --git a/cli/templates/overlays/backend/appwrite/lib/src/services/(usesAppwriteAuth)@auth_service.dart.hbs b/cli/templates/overlays/backend/appwrite/lib/src/services/(usesAppwriteAuth)@auth_service.dart.hbs new file mode 100644 index 0000000..9c8572f --- /dev/null +++ b/cli/templates/overlays/backend/appwrite/lib/src/services/(usesAppwriteAuth)@auth_service.dart.hbs @@ -0,0 +1,101 @@ +import 'dart:async'; +import '../utils/utils.dart'; +import '../config/app_config.dart'; +import 'package:appwrite/appwrite.dart'; + +class AuthService { + AuthService._(); + static final AuthService instance = AuthService._(); + + Account get _account => AppConfig.appwriteAccount; + + // Appwrite doesn't have a built-in auth state stream, so we manage our own + final StreamController?> _authStateController = + StreamController?>.broadcast(); + + /// Stream of auth state changes. Emits the current user map or null. + Stream?> get authStateChanges => _authStateController.stream; + + FutureEither?> login({ + required String email, + required String password, + }) async { + return runTask(() async { + await _account.createEmailPasswordSession( + email: email, + password: password + ); + final user = await _account.get(); + final userData = { + 'id': user.$id, + 'email': user.email, + 'name': user.name, + }; + _authStateController.add(userData); + return userData; + }, requiresNetwork: true); + } + + FutureEither?> signUp({ + required String name, + required String email, + required String password, + }) async { + return runTask(() async { + final user = await _account.create( + userId: ID.unique(), + email: email, + password: password, + name: name, + ); + // login after signup to get session + await _account.createEmailPasswordSession( + email: email, + password: password + ); + final userData = { + 'id': user.$id, + 'email': user.email, + 'name': user.name, + }; + _authStateController.add(userData); + return userData; + }, requiresNetwork: true); + } + + FutureEither forgotPassword({required String email}) async { + return runTask(() async { + // url for password recovery in your app + await _account.createRecovery( + email: email, + url: 'https://example.com/recovery' + ); + }, requiresNetwork: true); + } + + FutureEither logout() async { + return runTask(() async { + await _account.deleteSession(sessionId: 'current'); + _authStateController.add(null); + }, requiresNetwork: true); + } + + FutureEither?> getCurrentUser() async { + return runTask(() async { + try { + final user = await _account.get(); + return { + 'id': user.$id, + 'email': user.email, + 'name': user.name, + }; + } catch (e) { + return null; // session likely missing or invalid + } + }); + } + + void dispose() { + _authStateController.close(); + } +} diff --git a/cli/templates/overlays/backend/custom/lib/src/services/auth_service.dart.hbs b/cli/templates/overlays/backend/custom/lib/src/services/auth_service.dart.hbs new file mode 100644 index 0000000..0799112 --- /dev/null +++ b/cli/templates/overlays/backend/custom/lib/src/services/auth_service.dart.hbs @@ -0,0 +1,158 @@ +import 'dart:async'; +import '../utils/utils.dart'; +import '../config/app_config.dart'; +{{#if flags.usesDio}} +import 'package:dio/dio.dart'; +{{else if flags.usesHttp}} +import 'package:http/http.dart' as http; +import 'dart:convert'; +{{/if}} + +class AuthService { + AuthService._(); + static final AuthService instance = AuthService._(); + + {{#if flags.usesDio}} + Dio get _dio => AppConfig.dio; + {{else if flags.usesHttp}} + http.Client get _http => AppConfig.httpClient; + {{/if}} + + // Custom Backend doesn't have a built-in auth state stream, so we manage our own + final StreamController?> _authStateController = + StreamController?>.broadcast(); + + /// Stream of auth state changes. Emits the current user map or null. + Stream?> get authStateChanges => _authStateController.stream; + + FutureEither?> login({ + required String email, + required String password, + }) async { + return runTask(() async { + {{#if flags.usesDio}} + final response = await _dio.post>('/auth/login', data: { + 'email': email, + 'password': password, + }); + final data = response.data!; + _authStateController.add(data); + return data; + {{else if flags.usesHttp}} + final http.Response response = await _http.post( + Uri.parse('${AppConfig.baseUrl}/auth/login'), + body: jsonEncode({'email': email, 'password': password}), + headers: {'Content-Type': 'application/json'}, + ); + if (response.statusCode != 200) throw Exception('Login failed'); + final data = jsonDecode(response.body) as Map; + _authStateController.add(data); + return data; + {{else}} + // Mock implementation if no networking flag is set + await Future.delayed(const Duration(seconds: 1)); + if (email == 'admin@example.com' && password == 'admin123') { + final data = { + 'id': '1', + 'email': email, + 'name': 'Admin User', + }; + _authStateController.add(data); + return data; + } + throw Exception('Invalid credentials'); + {{/if}} + }, requiresNetwork: true); + } + + FutureEither?> signUp({ + required String name, + required String email, + required String password, + }) async { + return runTask(() async { + {{#if flags.usesDio}} + final response = await _dio.post>('/auth/signup', data: { + 'name': name, + 'email': email, + 'password': password, + }); + final data = response.data!; + _authStateController.add(data); + return data; + {{else if flags.usesHttp}} + final http.Response response = await _http.post( + Uri.parse('${AppConfig.baseUrl}/auth/signup'), + body: jsonEncode({ + 'name': name, + 'email': email, + 'password': password, + }), + headers: {'Content-Type': 'application/json'}, + ); + if (response.statusCode != 201) throw Exception('Signup failed'); + final data = jsonDecode(response.body) as Map; + _authStateController.add(data); + return data; + {{else}} + // Mock implementation if no networking flag is set + await Future.delayed(const Duration(seconds: 1)); + final data = { + 'id': '100', + 'email': email, + 'name': name, + }; + _authStateController.add(data); + return data; + {{/if}} + }, requiresNetwork: true); + } + + FutureEither forgotPassword({required String email}) async { + return runTask(() async { + {{#if flags.usesDio}} + await _dio.post('/auth/forgot-password', data: {'email': email}); + {{else if flags.usesHttp}} + final http.Response _ = await _http.post( + Uri.parse('${AppConfig.baseUrl}/auth/forgot-password'), + body: jsonEncode({'email': email}), + headers: {'Content-Type': 'application/json'}, + ); + {{else}} + await Future.delayed(const Duration(seconds: 1)); + {{/if}} + }, requiresNetwork: true); + } + + FutureEither logout() async { + return runTask(() async { + {{#if flags.usesDio}} + await _dio.post('/auth/logout'); + {{else if flags.usesHttp}} + final http.Response _ = await _http.post(Uri.parse('${AppConfig.baseUrl}/auth/logout')); + {{else}} + await Future.delayed(const Duration(seconds: 1)); + {{/if}} + _authStateController.add(null); + }, requiresNetwork: true); + } + + FutureEither?> getCurrentUser() async { + return runTask(() async { + {{#if flags.usesDio}} + final response = await _dio.get>('/auth/me'); + return response.data; + {{else if flags.usesHttp}} + final http.Response response = await _http.get(Uri.parse('${AppConfig.baseUrl}/auth/me')); + if (response.statusCode != 200) return null; + return jsonDecode(response.body) as Map; + {{else}} + return null; + {{/if}} + }); + } + + void dispose() { + _authStateController.close(); + } +} diff --git a/cli/templates/overlays/backend/firebase/lib/src/services/(usesFirebaseAuth)@auth_service.dart.hbs b/cli/templates/overlays/backend/firebase/lib/src/services/(usesFirebaseAuth)@auth_service.dart.hbs new file mode 100644 index 0000000..83915a2 --- /dev/null +++ b/cli/templates/overlays/backend/firebase/lib/src/services/(usesFirebaseAuth)@auth_service.dart.hbs @@ -0,0 +1,96 @@ +import '../utils/utils.dart'; +import '../config/app_config.dart'; +import 'package:firebase_auth/firebase_auth.dart'; + +class AuthService { + AuthService._(); + static final AuthService instance = AuthService._(); + + FirebaseAuth get _firebaseAuth => AppConfig.firebaseAuth; + + /// Stream of auth state changes. Emits the current user map or null. + Stream?> get authStateChanges { + return _firebaseAuth.authStateChanges().map((User? user) { + if (user == null) return null; + return { + 'id': user.uid, + 'email': user.email ?? '', + 'name': user.displayName ?? '', + 'photoUrl': user.photoURL, + }; + }); + } + + {{#if backend.options.authEmail}} + FutureEither?> login({ + required String email, + required String password, + }) async { + return runTask(() async { + final credentials = await _firebaseAuth.signInWithEmailAndPassword( + email: email, + password: password + ); + final user = credentials.user; + if (user == null) return null; + return { + 'id': user.uid, + 'email': user.email ?? '', + 'name': user.displayName ?? '', + 'photoUrl': user.photoURL, + }; + }, requiresNetwork: true); + } + + FutureEither?> signUp({ + required String name, + required String email, + required String password, + }) async { + return runTask(() async { + final credentials = await _firebaseAuth.createUserWithEmailAndPassword( + email: email, + password: password + ); + final user = credentials.user; + if (user == null) return null; + await user.updateDisplayName(name); + return { + 'id': user.uid, + 'email': user.email ?? '', + 'name': name, + 'photoUrl': user.photoURL, + }; + }, requiresNetwork: true); + } + + FutureEither forgotPassword({required String email}) async { + return runTask(() async { + await _firebaseAuth.sendPasswordResetEmail(email: email); + }, requiresNetwork: true); + } + {{/if}} + + FutureEither logout() async { + return runTask(() async { + await _firebaseAuth.signOut(); + }, requiresNetwork: true); + } + + FutureEither?> getCurrentUser() async { + return runTask(() async { + final user = _firebaseAuth.currentUser; + if (user == null) return null; + return { + 'id': user.uid, + 'email': user.email ?? '', + 'name': user.displayName ?? '', + 'photoUrl': user.photoURL, + }; + }); + } + + void dispose() { + // Firebase manages its own streams + } +} diff --git a/cli/templates/overlays/backend/supabase/lib/src/services/(usesSupabaseAuth)@auth_service.dart.hbs b/cli/templates/overlays/backend/supabase/lib/src/services/(usesSupabaseAuth)@auth_service.dart.hbs new file mode 100644 index 0000000..3eb4ac0 --- /dev/null +++ b/cli/templates/overlays/backend/supabase/lib/src/services/(usesSupabaseAuth)@auth_service.dart.hbs @@ -0,0 +1,97 @@ +import '../utils/utils.dart'; +import '../config/app_config.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +class AuthService { + AuthService._(); + static final AuthService instance = AuthService._(); + + SupabaseClient get _supabaseClient => AppConfig.supabase; + + /// Stream of auth state changes. Emits the current user map or null. + Stream?> get authStateChanges { + return _supabaseClient.auth.onAuthStateChange.map((data) { + final session = data.session; + if (session == null) return null; + final user = session.user; + return { + 'id': user.id, + 'email': user.email, + 'name': user.userMetadata?['name'] ?? '', + 'photoUrl': user.userMetadata?['avatar_url'], + }; + }); + } + + FutureEither?> login({ + required String email, + required String password, + }) async { + return runTask(() async { + final response = await _supabaseClient.auth.signInWithPassword( + email: email, + password: password, + ); + final user = response.user; + if (user == null) return null; + return { + 'id': user.id, + 'email': user.email, + 'name': user.userMetadata?['name'] ?? '', + 'photoUrl': user.userMetadata?['avatar_url'], + }; + }, requiresNetwork: true); + } + + FutureEither?> signUp({ + required String name, + required String email, + required String password, + }) async { + return runTask(() async { + final response = await _supabaseClient.auth.signUp( + email: email, + password: password, + data: {'name': name}, + ); + final user = response.user; + if (user == null) return null; + return { + 'id': user.id, + 'email': user.email, + 'name': name, + 'photoUrl': user.userMetadata?['avatar_url'], + }; + }, requiresNetwork: true); + } + + FutureEither forgotPassword({required String email}) async { + return runTask(() async { + await _supabaseClient.auth.resetPasswordForEmail(email); + }, requiresNetwork: true); + } + + FutureEither logout() async { + return runTask(() async { + await _supabaseClient.auth.signOut(); + }, requiresNetwork: true); + } + + FutureEither?> getCurrentUser() async { + return runTask(() async { + final session = _supabaseClient.auth.currentSession; + if (session == null) return null; + final user = session.user; + return { + 'id': user.id, + 'email': user.email, + 'name': user.userMetadata?['name'] ?? '', + 'photoUrl': user.userMetadata?['avatar_url'], + }; + }); + } + + void dispose() { + // Supabase manages its own streams + } +} diff --git a/cli/templates/overlays/device/app_version_update/lib/src/services/(usesAppVersionUpdate)@version_update_service.dart.hbs b/cli/templates/overlays/device/app_version_update/lib/src/services/(usesAppVersionUpdate)@version_update_service.dart.hbs new file mode 100644 index 0000000..cbcc556 --- /dev/null +++ b/cli/templates/overlays/device/app_version_update/lib/src/services/(usesAppVersionUpdate)@version_update_service.dart.hbs @@ -0,0 +1,118 @@ +import 'package:app_version_update/data/models/app_version_result.dart'; + +import '../imports/imports.dart'; + +/// A service to check for app updates and manage version information using +/// the `app_version_update` package. +class VersionUpdateService { + VersionUpdateService._(); + static final VersionUpdateService instance = VersionUpdateService._(); + + /// Check if a newer version of the app is available in the store. + FutureEither checkForUpdate({ + String? appleId, + String? playStoreId, + }) async { + return runTask(() async { + return await AppVersionUpdate.checkForUpdates( + appleId: appleId, + playStoreId: playStoreId, + ); + }, requiresNetwork: true); + } + + /// High-level method to check for updates and show the dialog automatically if available. + FutureEither checkAndShowUpdate({ + String? appleId, + String? playStoreId, + bool mandatory = false, + }) async { + return runTask(() async { + final result = await AppVersionUpdate.checkForUpdates( + appleId: appleId, + playStoreId: playStoreId, + ); + + if (result.canUpdate ?? false) { + final context = rootContext; + if (context == null) { + AppLogger.warning('Cannot show update dialog: rootContext is null'); + return; + } + + if(context.mounted){ + AppVersionUpdate.showAlertUpdate( + appVersionResult: result, + context: context, + mandatory: mandatory, + ); + } + } + }, requiresNetwork: true); + } + + /// Display a platform-specific update dialog. + FutureEither showUpdateAlert({ + required AppVersionResult updateResult, + bool mandatory = false, + String? title, + String? content, + String? cancelText, + String? updateText, + }) async { + return runTask(() async { + final context = rootContext; + if (context == null) return; + + AppVersionUpdate.showAlertUpdate( + appVersionResult: updateResult, + context: context, + mandatory: mandatory, + title: title ?? 'New version available', + content: content ?? 'Would you like to update your application?', + cancelButtonText: cancelText ?? 'UPDATE LATER', + updateButtonText: updateText ?? 'UPDATE', + ); + }); + } + + /// Display a platform-specific update bottom sheet. + FutureEither showUpdateBottomSheet({ + required AppVersionResult updateResult, + bool mandatory = false, + String? title, + Widget? content, + }) async { + return runTask(() async { + final context = rootContext; + if (context == null) return; + + AppVersionUpdate.showBottomSheetUpdate( + appVersionResult: updateResult, + context: context, + mandatory: mandatory, + title: title ?? 'New version available', + content: content, + ); + }); + } + + /// Display a dedicated update page. + FutureEither showUpdatePage({ + required AppVersionResult updateResult, + bool mandatory = false, + Widget? page, + }) async { + return runTask(() async { + final context = rootContext; + if (context == null) return; + + AppVersionUpdate.showPageUpdate( + appVersionResult: updateResult, + context: context, + mandatory: mandatory, + page: page, + ); + }); + } +} \ No newline at end of file diff --git a/cli/templates/overlays/device/device_info/lib/src/services/(usesDeviceInfoPlus)@device_info_service.dart.hbs b/cli/templates/overlays/device/device_info/lib/src/services/(usesDeviceInfoPlus)@device_info_service.dart.hbs new file mode 100644 index 0000000..5d9e45c --- /dev/null +++ b/cli/templates/overlays/device/device_info/lib/src/services/(usesDeviceInfoPlus)@device_info_service.dart.hbs @@ -0,0 +1,54 @@ +import 'dart:io'; +import 'package:device_info_plus/device_info_plus.dart'; +import '../utils/utils.dart'; + +/// A service to retrieve detailed information about the current device. +class DeviceInfoService { + DeviceInfoService._(); + static final DeviceInfoService instance = DeviceInfoService._(); + + final DeviceInfoPlugin _deviceInfo = DeviceInfoPlugin(); + + /// Retrieve full device information as a Map. + FutureEither> getFullDeviceInfo() async { + return runTask(() async { + if (Platform.isAndroid) { + final androidInfo = await _deviceInfo.androidInfo; + return { + 'model': androidInfo.model, + 'manufacturer': androidInfo.manufacturer, + 'version': androidInfo.version.release, + 'sdkInt': androidInfo.version.sdkInt, + 'id': androidInfo.id, + 'isPhysicalDevice': androidInfo.isPhysicalDevice, + }; + } else if (Platform.isIOS) { + final iosInfo = await _deviceInfo.iosInfo; + return { + 'name': iosInfo.name, + 'model': iosInfo.model, + 'systemName': iosInfo.systemName, + 'systemVersion': iosInfo.systemVersion, + 'identifierForVendor': iosInfo.identifierForVendor, + 'isPhysicalDevice': iosInfo.isPhysicalDevice, + }; + } else if (Platform.isMacOS) { + final macInfo = await _deviceInfo.macOsInfo; + return { + 'computerName': macInfo.computerName, + 'hostName': macInfo.hostName, + 'model': macInfo.model, + 'osRelease': macInfo.osRelease, + }; + } else if (Platform.isWindows) { + final winInfo = await _deviceInfo.windowsInfo; + return { + 'computerName': winInfo.computerName, + 'numberOfCores': winInfo.numberOfCores, + 'systemMemoryInMegabytes': winInfo.systemMemoryInMegabytes, + }; + } + return {'platform': 'unknown'}; + }); + } +} diff --git a/cli/templates/overlays/extras/flavors/lib/src/flavors.dart.hbs b/cli/templates/overlays/extras/flavors/lib/src/flavors.dart.hbs new file mode 100644 index 0000000..29a3524 --- /dev/null +++ b/cli/templates/overlays/extras/flavors/lib/src/flavors.dart.hbs @@ -0,0 +1,15 @@ +enum Flavor { dev, staging, prod } + +class FlavorConfig { +FlavorConfig(this.flavor); + +final Flavor flavor; + +static late FlavorConfig current; + +static void load(Flavor flavor) { +current = FlavorConfig(flavor); +} + +static String get name => current.flavor.name; +} \ No newline at end of file diff --git a/cli/templates/overlays/extras/localization/assets/translations/en.json.hbs b/cli/templates/overlays/extras/localization/assets/translations/en.json.hbs new file mode 100644 index 0000000..fa8965a --- /dev/null +++ b/cli/templates/overlays/extras/localization/assets/translations/en.json.hbs @@ -0,0 +1,47 @@ +{ + "shared": { + "get_started": "Get Started" + }, + "onboarding": { + "onboarding_title_1": "Your Journey,\nPerfectly Planned", + "onboarding_subtitle_1": "Effortlessly create and organize your\ndream trips. Start exploring now!", + "onboarding_title_2": "Discover\nFriends Nearby", + "onboarding_subtitle_2": "See where your friends are traveling and\nexplore the world together.", + "onboarding_title_3": "Stay Updated\nwith Top Places", + "onboarding_subtitle_3": "Find trending destinations and must-see attractions,\nall tailored to enhance your travel plans." + }, + "home": { + "home_title": "Home", + "welcome_home": "Welcome Home!", + "home_subtitle": "You have successfully completed the onboarding process." + }, + "auth": { + "log_in": "Log in", + "log_in_subtitle": "Enter your email and password", + "email": "Email", + "email_required": "Email is required", + "email_invalid": "Enter a valid email", + "password": "Password", + "password_required": "Password is required", + "password_too_short": "Password must be at least 6 characters", + "remember_me": "Remember me", + "forgot_password": "Forgot Password", + "login_button": "Login", + "dont_have_account": "Don't have an account? ", + "sign_up": "Sign Up", + "create_account": "Create Account", + "create_account_subtitle": "Create a new account to get started.", + "name": "Name", + "name_required": "Name is required", + "confirm_password": "Confirm Password", + "confirm_password_required": "Confirm password is required", + "passwords_do_not_match": "Passwords do not match", + "create_account_button": "Create Account", + "already_have_account": "Already have an account? ", + "sign_in": "Sign In", + "or_continue_with": "Or Continue With Account", + "forgot_password_title": "Forgot Password", + "forgot_password_subtitle": "Enter your email address to receive a reset link\nand regain access to your account.", + "reset_link_sent": "Password reset link sent to your email." + } +} diff --git a/cli/templates/overlays/media/lib/src/services/(usesImagePicker,usesFilePicker)@media_service.dart.hbs b/cli/templates/overlays/media/lib/src/services/(usesImagePicker,usesFilePicker)@media_service.dart.hbs new file mode 100644 index 0000000..38e143b --- /dev/null +++ b/cli/templates/overlays/media/lib/src/services/(usesImagePicker,usesFilePicker)@media_service.dart.hbs @@ -0,0 +1,148 @@ +import 'dart:io'; +{{#if flags.usesImagePicker}} +import 'package:image_picker/image_picker.dart'; +{{/if}} +{{#if flags.usesFilePicker}} +import 'package:file_picker/file_picker.dart'; +{{/if}} +{{#if flags.usesPermissionHandler}} +import 'package:permission_handler/permission_handler.dart'; +{{/if}} +import '../utils/utils.dart'; + +/// A service to handle media selection (images, videos, files). +class MediaService { + MediaService._(); + static final MediaService instance = MediaService._(); + + {{#if flags.usesImagePicker}} + final ImagePicker _imagePicker = ImagePicker(); + + /// Pick an image from gallery or camera. + FutureEither pickImage({ + required ImageSource source, + double? maxWidth, + double? maxHeight, + int? imageQuality, + }) async { + return runTask(() async { + {{#if flags.usesPermissionHandler}} + // Check permissions + if (source == ImageSource.camera) { + final status = await Permission.camera.request(); + if (!status.isGranted) { + throw Exception('Camera permission denied'); + } + } else { + if (Platform.isAndroid || Platform.isIOS) { + final status = await Permission.photos.request(); + if (!status.isGranted && !status.isLimited) { + throw Exception('Photos permission denied'); + } + } + } + {{/if}} + + final XFile? file = await _imagePicker.pickImage( + source: source, + maxWidth: maxWidth, + maxHeight: maxHeight, + imageQuality: imageQuality, + ); + + return file != null ? File(file.path) : null; + }); + } + + /// Pick multiple images from gallery. + FutureEither> pickMultiImage({ + double? maxWidth, + double? maxHeight, + int? imageQuality, + }) async { + return runTask(() async { + {{#if flags.usesPermissionHandler}} + if (Platform.isAndroid || Platform.isIOS) { + final status = await Permission.photos.request(); + if (!status.isGranted && !status.isLimited) { + throw Exception('Photos permission denied'); + } + } + {{/if}} + + final List files = await _imagePicker.pickMultiImage( + maxWidth: maxWidth, + maxHeight: maxHeight, + imageQuality: imageQuality, + ); + + return files.map((file) => File(file.path)).toList(); + }); + } + + /// Pick a video from gallery or camera. + FutureEither pickVideo({ + required ImageSource source, + Duration? maxDuration, + }) async { + return runTask(() async { + {{#if flags.usesPermissionHandler}} + if (source == ImageSource.camera) { + final status = await Permission.camera.request(); + if (!status.isGranted) { + throw Exception('Camera permission denied'); + } + } else { + if (Platform.isAndroid || Platform.isIOS) { + final status = await Permission.photos.request(); + if (!status.isGranted && !status.isLimited) { + throw Exception('Photos permission denied'); + } + } + } + {{/if}} + + final XFile? file = await _imagePicker.pickVideo( + source: source, + maxDuration: maxDuration, + ); + + return file != null ? File(file.path) : null; + }); + } + {{/if}} + + {{#if flags.usesFilePicker}} + /// Pick one or more files from the device. + FutureEither> pickFiles({ + FileType type = FileType.any, + List? allowedExtensions, + bool allowMultiple = false, + }) async { + return runTask(() async { + {{#if flags.usesPermissionHandler}} + if (Platform.isAndroid) { + final status = await Permission.storage.request(); + if (!status.isGranted) { + // Note: On Android 13+, storage permission might be handled differently (media-specific) + // but permission_handler usually handles the abstraction. + } + } + {{/if}} + + final FilePickerResult? result = await FilePicker.platform.pickFiles( + type: type, + allowedExtensions: allowedExtensions, + allowMultiple: allowMultiple, + ); + + if (result == null || result.files.isEmpty) return []; + + return result.paths + .where((path) => path != null) + .map((path) => File(path!)) + .toList(); + }); + } + {{/if}} +} diff --git a/cli/templates/overlays/networking/cached_image/lib/src/shared/widgets/app_cached_image.dart.hbs b/cli/templates/overlays/networking/cached_image/lib/src/shared/widgets/app_cached_image.dart.hbs new file mode 100644 index 0000000..8dbbc19 --- /dev/null +++ b/cli/templates/overlays/networking/cached_image/lib/src/shared/widgets/app_cached_image.dart.hbs @@ -0,0 +1,160 @@ +import '../../imports/core_imports.dart'; +import '../../imports/packages_imports.dart'; + + +/// A premium, highly customizable wrapper around [CachedNetworkImage]. +/// +/// This widget provides smooth transitions, specialized error handling, +/// and integrates with the project's design system. +class AppCachedImage extends StatelessWidget { + /// The URL of the image to display. + final String imageUrl; + + /// Optional width for the image. + final double? width; + + /// Optional height for the image. + final double? height; + + /// How the image should be inscribed into the box. + final BoxFit fit; + + /// Optional placeholder displayed while the image is loading. + /// If null, a shimmer or loading indicator is shown. + final Widget? placeholder; + + /// Optional widget displayed if the image fails to load. + final Widget? errorWidget; + + /// [Optional] color to be combined with the image. + final Color? color; + + /// [Optional] blend mode for the [color]. + final BlendMode? colorBlendMode; + + /// The borderRadius of the image. + final BorderRadius? borderRadius; + + /// The duration of the fade-in animation. + final Duration? fadeInDuration; + + /// How to align the image within its bounds. + final Alignment alignment; + + /// If true, the image will be wrapped in a [Skeletonizer] during loading. + final bool useSkeleton; + + /// Optional key to use for caching. + final String? cacheKey; + + const AppCachedImage({ + super.key, + required this.imageUrl, + this.width, + this.height, + this.fit = BoxFit.cover, + this.placeholder, + this.errorWidget, + this.color, + this.colorBlendMode, + this.borderRadius, + this.fadeInDuration, + this.alignment = Alignment.center, + this.useSkeleton = true, + this.cacheKey, + }); + + @override + Widget build(BuildContext context) { + // Adjust sizing for screenutil if enabled + final double? adjustedWidth = {{#if flags.usesScreenutil}}width?.w{{else}}width{{/if}}; + final double? adjustedHeight = {{#if flags.usesScreenutil}}height?.h{{else}}height{{/if}}; + + Widget imageContent = CachedNetworkImage( + imageUrl: imageUrl, + cacheKey: cacheKey, + width: adjustedWidth, + height: adjustedHeight, + fit: fit, + color: color, + colorBlendMode: colorBlendMode, + alignment: alignment, + fadeInDuration: fadeInDuration ?? const Duration(milliseconds: 500), + placeholder: (context, url) => placeholder ?? _buildDefaultPlaceholder(context), + errorWidget: (context, url, error) => errorWidget ?? _buildDefaultErrorWidget(context), + ); + + if (borderRadius != null) { + imageContent = ClipRRect( + borderRadius: borderRadius!, + child: imageContent, + ); + } + + return imageContent; + } + + Widget _buildDefaultPlaceholder(BuildContext context) { + if (useSkeleton) { + {{#if flags.usesSkeletonizer}} + return Skeletonizer( + enabled: true, + child: Container( + width: width, + height: height, + decoration: BoxDecoration( + color: context.theme.colorScheme.surfaceContainerHighest, + borderRadius: borderRadius, + ), + ), + ); + {{else}} + return _buildLoadingIndicator(context); + {{/if}} + } + return _buildLoadingIndicator(context); + } + + Widget _buildLoadingIndicator(BuildContext context) { + return Container( + width: width, + height: height, + color: context.theme.colorScheme.surfaceContainerHighest .withValues(alpha: 0.9), + child: const Center( + child: CircularProgressIndicator(strokeWidth: 2), + ), + ); + } + + Widget _buildDefaultErrorWidget(BuildContext context) { + return Container( + width: width, + height: height, + color: context.theme.colorScheme.errorContainer .withValues(alpha: 0.9), + child: Center( + child: {{#if flags.usesHugeicons}} + HugeIcon( + icon: HugeIcons.strokeRoundedImageNotFound02, + color: context.theme.colorScheme.error, + size: 24, + ) + {{else if flags.usesIconsaxPlus}} + Icon( + IconsaxPlusBold.image, + color: context.theme.colorScheme.error, + ) + {{else if flags.usesFlutterRemix}} + Icon( + FlutterRemix.image_line, + color: context.theme.colorScheme.error, + ) + {{else}} + Icon( + Icons.broken_image_outlined, + color: context.theme.colorScheme.error, + ) + {{/if}}, + ), + ); + } +} diff --git a/cli/templates/overlays/networking/dio/lib/src/services/(usesDio)@dio_service.dart.hbs b/cli/templates/overlays/networking/dio/lib/src/services/(usesDio)@dio_service.dart.hbs new file mode 100644 index 0000000..2b55f74 --- /dev/null +++ b/cli/templates/overlays/networking/dio/lib/src/services/(usesDio)@dio_service.dart.hbs @@ -0,0 +1,50 @@ +import 'package:dio/dio.dart'; +import '../config/app_config.dart'; +import '../utils/utils.dart'; + +/// A robust networking service powered by Dio. +class DioService { + DioService._(); + static final DioService instance = DioService._(); + + // --- HTTP Methods --- + + FutureEither> get( + String path, { + Map? queryParameters, + }) { + return runTask(() => AppConfig.dio.get(path, queryParameters: queryParameters), requiresNetwork: true); + } + + FutureEither> post( + String path, { + dynamic data, + Map? queryParameters, + }) { + return runTask(() => AppConfig.dio.post(path, data: data, queryParameters: queryParameters), requiresNetwork: true); + } + + FutureEither> put( + String path, { + dynamic data, + Map? queryParameters, + }) { + return runTask(() => AppConfig.dio.put(path, data: data, queryParameters: queryParameters), requiresNetwork: true); + } + + FutureEither> patch( + String path, { + dynamic data, + Map? queryParameters, + }) { + return runTask(() => AppConfig.dio.patch(path, data: data, queryParameters: queryParameters), requiresNetwork: true); + } + + FutureEither> delete( + String path, { + dynamic data, + Map? queryParameters, + }) { + return runTask(() => AppConfig.dio.delete(path, data: data, queryParameters: queryParameters), requiresNetwork: true); + } +} diff --git a/cli/templates/overlays/networking/http/lib/src/services/(usesHttp)@http_service.dart.hbs b/cli/templates/overlays/networking/http/lib/src/services/(usesHttp)@http_service.dart.hbs new file mode 100644 index 0000000..30ee03d --- /dev/null +++ b/cli/templates/overlays/networking/http/lib/src/services/(usesHttp)@http_service.dart.hbs @@ -0,0 +1,82 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import '../config/app_config.dart'; +import '../utils/utils.dart'; + +/// A lightweight networking service powered by the http package. +class HttpService { + HttpService._(); + static final HttpService instance = HttpService._(); + + // --- HTTP Methods --- + + FutureEither get(String path, {Map? headers}) { + return runTask(() async { + final response = await AppConfig.httpClient.get( + Uri.parse('${AppConfig.baseUrl}$path'), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + ...?headers, + }, + ); + _logResponse(response); + return response; + }, requiresNetwork: true); + } + + FutureEither post(String path, {dynamic body, Map? headers}) { + return runTask(() async { + final response = await AppConfig.httpClient.post( + Uri.parse('${AppConfig.baseUrl}$path'), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + ...?headers, + }, + body: jsonEncode(body), + ); + _logResponse(response); + return response; + }, requiresNetwork: true); + } + + FutureEither put(String path, {dynamic body, Map? headers}) { + return runTask(() async { + final response = await AppConfig.httpClient.put( + Uri.parse('${AppConfig.baseUrl}$path'), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + ...?headers, + }, + body: jsonEncode(body), + ); + _logResponse(response); + return response; + }, requiresNetwork: true); + } + + FutureEither delete(String path, {Map? headers}) { + return runTask(() async { + final response = await AppConfig.httpClient.delete( + Uri.parse('${AppConfig.baseUrl}$path'), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + ...?headers, + }, + ); + _logResponse(response); + return response; + }, requiresNetwork: true); + } + + void _logResponse(http.Response response) { + if (response.statusCode >= 200 && response.statusCode < 300) { + AppLogger.debug('✅ [HTTP] RESPONSE[${response.statusCode}] => ${response.request?.url}'); + } else { + AppLogger.error('❌ [HTTP] RESPONSE[${response.statusCode}] => ${response.request?.url}'); + } + } +} diff --git a/cli/templates/overlays/storage/hive/lib/src/services/(usesHive)@hive_service.dart.hbs b/cli/templates/overlays/storage/hive/lib/src/services/(usesHive)@hive_service.dart.hbs new file mode 100644 index 0000000..bd7ec30 --- /dev/null +++ b/cli/templates/overlays/storage/hive/lib/src/services/(usesHive)@hive_service.dart.hbs @@ -0,0 +1,59 @@ +import 'package:hive_ce_flutter/hive_ce_flutter.dart'; +import '../utils/utils.dart'; + +/// A robust [Hive] storage service for local NoSQL persistence. +class HiveService { + HiveService._(); + static final HiveService instance = HiveService._(); + + /// Initialize Hive and open a default box. + FutureEither init() async { + return runTask(() async { + await Hive.initFlutter(); + await Hive.openBox('app_box'); + AppLogger.info('Hive initialized'); + }); + } + + /// Get data from the default box. + T? get(String key, {T? defaultValue}) { + try { + final box = Hive.box('app_box'); + return box.get(key, defaultValue: defaultValue) as T?; + } catch (e) { + AppLogger.error('Hive get error', e); + return defaultValue; + } + } + + /// Put data into the default box. + FutureEither put(String key, dynamic value) async { + return runTask(() async { + final box = Hive.box('app_box'); + await box.put(key, value); + }); + } + + /// Delete data from the default box. + FutureEither delete(String key) async { + return runTask(() async { + final box = Hive.box('app_box'); + await box.delete(key); + }); + } + + /// Clear all data from the default box. + FutureEither clear() async { + return runTask(() async { + final box = Hive.box('app_box'); + await box.clear(); + }); + } + + /// Open a custom box for specialized storage. + FutureEither> openCustomBox(String name) async { + return runTask(() async { + return await Hive.openBox(name); + }); + } +} diff --git a/cli/templates/overlays/storage/secure_storage/lib/src/services/(usesSecureStorage)@secure_storage_service.dart.hbs b/cli/templates/overlays/storage/secure_storage/lib/src/services/(usesSecureStorage)@secure_storage_service.dart.hbs new file mode 100644 index 0000000..194cb5f --- /dev/null +++ b/cli/templates/overlays/storage/secure_storage/lib/src/services/(usesSecureStorage)@secure_storage_service.dart.hbs @@ -0,0 +1,39 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import '../utils/utils.dart'; + +/// A service to securely store sensitive data like JWT tokens or API keys. +/// +/// Uses [FlutterSecureStorage] which utilizes Keychain (iOS) and Keystore (Android). +class SecureStorageService { + SecureStorageService._(); + static final SecureStorageService instance = SecureStorageService._(); + + final _storage = const FlutterSecureStorage( + aOptions: AndroidOptions.defaultOptions, + ); + + /// Write a sensitive value to secure storage. + FutureEither write(String key, String value) async { + return runTask(() => _storage.write(key: key, value: value)); + } + + /// Read a sensitive value from secure storage. + FutureEither read(String key) async { + return runTask(() => _storage.read(key: key)); + } + + /// Delete a specific key from secure storage. + FutureEither delete(String key) async { + return runTask(() => _storage.delete(key: key)); + } + + /// Wipe all data from secure storage. + FutureEither deleteAll() async { + return runTask(() => _storage.deleteAll()); + } + + /// Check if a key exists in secure storage. + FutureEither containsKey(String key) async { + return runTask(() => _storage.containsKey(key: key)); + } +} diff --git a/cli/templates/overlays/storage/shared_preferences/lib/src/services/(usesSharedPreferences)@storage_service.dart.hbs b/cli/templates/overlays/storage/shared_preferences/lib/src/services/(usesSharedPreferences)@storage_service.dart.hbs new file mode 100644 index 0000000..4be9761 --- /dev/null +++ b/cli/templates/overlays/storage/shared_preferences/lib/src/services/(usesSharedPreferences)@storage_service.dart.hbs @@ -0,0 +1,53 @@ +import 'package:shared_preferences/shared_preferences.dart'; +import '../utils/utils.dart'; + +/// A wrapper around [SharedPreferences] for simple key-value persistence. +class StorageService { + StorageService._(); + static final StorageService instance = StorageService._(); + + late final SharedPreferences _prefs; + + /// Initialize SharedPreferences instance. + FutureEither init() async { + return runTask(() async { + _prefs = await SharedPreferences.getInstance(); + AppLogger.info('StorageService (SharedPreferences) initialized'); + }); + } + + // --- SETTERS --- + + FutureEither setString(String key, String value) async => + runTask(() => _prefs.setString(key, value)); + + FutureEither setBool(String key, bool value) async => + runTask(() => _prefs.setBool(key, value)); + + FutureEither setInt(String key, int value) async => + runTask(() => _prefs.setInt(key, value)); + + FutureEither setDouble(String key, double value) async => + runTask(() => _prefs.setDouble(key, value)); + + FutureEither setStringList(String key, List value) async => + runTask(() => _prefs.setStringList(key, value)); + + // --- GETTERS --- + + String? getString(String key) => _prefs.getString(key); + bool? getBool(String key) => _prefs.getBool(key); + int? getInt(String key) => _prefs.getInt(key); + double? getDouble(String key) => _prefs.getDouble(key); + List? getStringList(String key) => _prefs.getStringList(key); + + // --- COMMON --- + + bool containsKey(String key) => _prefs.containsKey(key); + + FutureEither remove(String key) async => + runTask(() => _prefs.remove(key)); + + FutureEither clear() async => + runTask(() => _prefs.clear()); +} diff --git a/cli/templates/overlays/utilities/geolocator/lib/src/services/(usesGeolocator)@location_service.dart.hbs b/cli/templates/overlays/utilities/geolocator/lib/src/services/(usesGeolocator)@location_service.dart.hbs new file mode 100644 index 0000000..3825f5b --- /dev/null +++ b/cli/templates/overlays/utilities/geolocator/lib/src/services/(usesGeolocator)@location_service.dart.hbs @@ -0,0 +1,78 @@ +import 'package:geolocator/geolocator.dart'; +import '../utils/utils.dart'; + +/// A service to handle device location requests and status checks. +class LocationService { + LocationService._(); + static final LocationService instance = LocationService._(); + + /// Check the status of location permission. + FutureEither checkPermission() async { + return runTask(() => Geolocator.checkPermission()); + } + + /// Request location permission. + FutureEither requestPermission() async { + return runTask(() => Geolocator.requestPermission()); + } + + /// Check if location services are enabled. + FutureEither isLocationServiceEnabled() async { + return runTask(() => Geolocator.isLocationServiceEnabled()); + } + + /// Open the location settings. + FutureEither openLocationSettings() async { + return runTask(() => Geolocator.openLocationSettings()); + } + + /// Get the current position. + FutureEither getCurrentPosition({ + LocationAccuracy accuracy = LocationAccuracy.high, + }) async { + return runTask(() async { + bool serviceEnabled; + LocationPermission permission; + + serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) { + throw Exception('Location services are disabled.'); + } + + permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + if (permission == LocationPermission.denied) { + throw Exception('Location permissions are denied.'); + } + } + + if (permission == LocationPermission.deniedForever) { + throw Exception( + 'Location permissions are permanently denied, we cannot request permissions.'); + } + + return await Geolocator.getCurrentPosition( + locationSettings: LocationSettings(accuracy: accuracy), + ); + }); + } + + /// Get the last known position. + FutureEither getLastKnownPosition() async { + return runTask(() => Geolocator.getLastKnownPosition()); + } + + /// Get a stream of position updates. + Stream getPositionStream({ + LocationAccuracy accuracy = LocationAccuracy.high, + int distanceFilter = 0, + }) { + return Geolocator.getPositionStream( + locationSettings: LocationSettings( + accuracy: accuracy, + distanceFilter: distanceFilter, + ), + ); + } +} diff --git a/cli/templates/overlays/utilities/path_provider/lib/src/services/(usesPathProvider)@path_service.dart.hbs b/cli/templates/overlays/utilities/path_provider/lib/src/services/(usesPathProvider)@path_service.dart.hbs new file mode 100644 index 0000000..2d91706 --- /dev/null +++ b/cli/templates/overlays/utilities/path_provider/lib/src/services/(usesPathProvider)@path_service.dart.hbs @@ -0,0 +1,30 @@ +import 'dart:io'; +import 'package:path_provider/path_provider.dart'; +import '../utils/utils.dart'; + +/// A service to easily access platform-specific file system locations. +class PathService { + PathService._(); + static final PathService instance = PathService._(); + + /// Get the directory where the application may place data that is user-generated. + FutureEither getDocumentsDirectory() async => + runTask(() => getApplicationDocumentsDirectory()); + + /// Get the directory where the application may place application-specific cache files. + FutureEither getTempDirectory() async => + runTask(() => getTemporaryDirectory()); + + /// Get the directory where the application may place data that is specific to + /// the application and not meant to be seen by the user. + FutureEither getAppSupportDirectory() async => + runTask(() => getApplicationSupportDirectory()); + + /// Get the directory where current application-specific data may be found. + FutureEither getAppLibraryDirectory() async => + runTask(() => getLibraryDirectory()); + + /// Get the path to the external storage directory (Android only). + FutureEither getExternalStorageDirectoryPath() async => + runTask(() => getExternalStorageDirectory()); +} diff --git a/cli/templates/overlays/utilities/permission_handler/lib/src/services/(usesPermissionHandler)@permission_service.dart.hbs b/cli/templates/overlays/utilities/permission_handler/lib/src/services/(usesPermissionHandler)@permission_service.dart.hbs new file mode 100644 index 0000000..79c40d1 --- /dev/null +++ b/cli/templates/overlays/utilities/permission_handler/lib/src/services/(usesPermissionHandler)@permission_service.dart.hbs @@ -0,0 +1,28 @@ +import 'package:permission_handler/permission_handler.dart'; +import '../utils/utils.dart'; + +/// A service to handle device permission requests and status checks. +class PermissionService { + PermissionService._(); + static final PermissionService instance = PermissionService._(); + + /// Check the status of a specific permission. + FutureEither checkStatus(Permission permission) async { + return runTask(() => permission.status); + } + + /// Request a specific permission. + FutureEither request(Permission permission) async { + return runTask(() => permission.request()); + } + + /// Request multiple permissions at once. + FutureEither> requestMultiple(List permissions) async { + return runTask(() => permissions.request()); + } + + /// Open the app settings. + FutureEither openSettings() async { + return runTask(() => openAppSettings()); + } +} diff --git a/cli/templates/overlays/utilities/permission_handler/lib/src/shared/hooks/use_permission.dart.hbs b/cli/templates/overlays/utilities/permission_handler/lib/src/shared/hooks/use_permission.dart.hbs new file mode 100644 index 0000000..5aeef19 --- /dev/null +++ b/cli/templates/overlays/utilities/permission_handler/lib/src/shared/hooks/use_permission.dart.hbs @@ -0,0 +1,44 @@ +{{#if flags.usesFlutterHooks}} +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:permission_handler/permission_handler.dart'; +import '../../services/permission_service.dart'; + +/// A hook to manage permission lifecycle and status with loading state. +(PermissionStatus, bool, Future Function()) usePermission(Permission permission) { + final status = useState(PermissionStatus.denied); + final isLoading = useState(false); + + Future check() async { + isLoading.value = true; + try { + final result = await PermissionService.instance.checkStatus(permission); + result.fold( + (f) => null, + (s) => status.value = s, + ); + } finally { + isLoading.value = false; + } + } + + Future request() async { + isLoading.value = true; + try { + final result = await PermissionService.instance.request(permission); + result.fold( + (f) => null, + (s) => status.value = s, + ); + } finally { + isLoading.value = false; + } + } + + useEffect(() { + check(); + return null; + }, [permission]); + + return (status.value, isLoading.value, request); +} +{{/if}} diff --git a/cli/templates/overlays/utilities/share_plus/lib/src/services/(usesSharePlus)@share_service.dart.hbs b/cli/templates/overlays/utilities/share_plus/lib/src/services/(usesSharePlus)@share_service.dart.hbs new file mode 100644 index 0000000..6db71d9 --- /dev/null +++ b/cli/templates/overlays/utilities/share_plus/lib/src/services/(usesSharePlus)@share_service.dart.hbs @@ -0,0 +1,22 @@ +import 'package:share_plus/share_plus.dart'; +import '../utils/utils.dart'; + +/// A service to handle sharing content via the platform's native share dialog. +class ShareService { + ShareService._(); + static final ShareService instance = ShareService._(); + + /// Share plain text content. + FutureEither shareText(String text, {String? subject}) async { + return runTask(() => Share.share(text, subject: subject)); + } + + /// Share files. + FutureEither shareFiles(List paths, {String? text, String? subject}) async { + return runTask(() => Share.shareXFiles( + paths.map((p) => XFile(p)).toList(), + text: text, + subject: subject, + )); + } +} diff --git a/cli/templates/overlays/utilities/share_plus/lib/src/shared/hooks/use_share.dart.hbs b/cli/templates/overlays/utilities/share_plus/lib/src/shared/hooks/use_share.dart.hbs new file mode 100644 index 0000000..497e42a --- /dev/null +++ b/cli/templates/overlays/utilities/share_plus/lib/src/shared/hooks/use_share.dart.hbs @@ -0,0 +1,26 @@ +{{#if flags.usesFlutterHooks}} +import 'package:flutter_hooks/flutter_hooks.dart'; +import '../../utils/utils.dart'; +import '../../services/share_service.dart'; + +/// A hook to simplify sharing logic within widgets with loading state. +(bool, Future Function(String, {String? subject})) useShare() { + final isLoading = useState(false); + final service = ShareService.instance; + + Future share(String text, {String? subject}) async { + isLoading.value = true; + try { + final result = await service.shareText(text, subject: subject); + result.fold( + (failure) => AppLogger.error('Sharing failed: ${failure.message}'), + (success) => AppLogger.info('Sharing result: ${success.status}'), + ); + } finally { + isLoading.value = false; + } + } + + return (isLoading.value, share); +} +{{/if}} diff --git a/cli/templates/overlays/utilities/url_launcher/lib/src/services/(usesUrlLauncher)@url_launcher_service.dart.hbs b/cli/templates/overlays/utilities/url_launcher/lib/src/services/(usesUrlLauncher)@url_launcher_service.dart.hbs new file mode 100644 index 0000000..70bc64a --- /dev/null +++ b/cli/templates/overlays/utilities/url_launcher/lib/src/services/(usesUrlLauncher)@url_launcher_service.dart.hbs @@ -0,0 +1,37 @@ +import 'dart:io'; +import '../imports/imports.dart'; + +/// A service to handle URL launching operations. +class UrlLauncherService { + UrlLauncherService._(); + static final UrlLauncherService instance = UrlLauncherService._(); + + /// Launch a URL string. + FutureEither launch(String url, {LaunchMode? mode}) async { + return runTask(() async { + final formattedUrl = _formatUrl(url); + final uri = Uri.parse(formattedUrl); + + if (await canLaunchUrl(uri)) { + await launchUrl( + uri, + mode: mode ?? LaunchMode.externalApplication, + ); + } else { + throw Exception('Could not launch url: $formattedUrl'); + } + }); + } + + String _formatUrl(String url) { + if (url.isValidUrl && !url.contains('://')) { + return 'https://$url'; + } + if (url.isValidPhoneNumber) { + return Platform.isAndroid + ? 'whatsapp://send?phone=$url' + : 'https://wa.me/$url'; + } + return url; + } +} diff --git a/cli/templates/overlays/utilities/url_launcher/lib/src/shared/hooks/use_launch_url.dart.hbs b/cli/templates/overlays/utilities/url_launcher/lib/src/shared/hooks/use_launch_url.dart.hbs new file mode 100644 index 0000000..2c6f98f --- /dev/null +++ b/cli/templates/overlays/utilities/url_launcher/lib/src/shared/hooks/use_launch_url.dart.hbs @@ -0,0 +1,52 @@ +{{#if flags.usesFlutterHooks}} +import 'dart:io'; +import '../../imports/imports.dart'; + +/// A hook to handle URL launching with state feedback. +(bool, Future Function(String)) useLaunchUrl({LaunchMode? mode}) { + final isLoading = useState(false); + + Future launch(String url) async { + isLoading.value = true; + try { + final formattedUrl = _formatUrl(url); + final uri = Uri.parse(formattedUrl); + + if (await canLaunchUrl(uri)) { + await launchUrl( + uri, + mode: mode ?? LaunchMode.externalApplication, + ); + } else { + AppLogger.error('Could not launch url: $formattedUrl'); + showGlobalToast( + message: 'Could not launch url', + status: 'error', + ); + } + } catch (e) { + AppLogger.error('Error launching URL: $e'); + showGlobalToast( + message: 'Could not launch url', + status: 'error', + ); + } finally { + isLoading.value = false; + } + } + + return (isLoading.value, launch); +} + +String _formatUrl(String url) { + if (url.isValidUrl && !url.contains('://')) { + return 'https://$url'; + } + if (url.isValidPhoneNumber) { + return Platform.isAndroid + ? 'whatsapp://send?phone=$url' + : 'https://wa.me/$url'; + } + return url; +} +{{/if}} diff --git a/cli/templates/partials/features/auth/auth_logic.hbs b/cli/templates/partials/features/auth/auth_logic.hbs new file mode 100644 index 0000000..584552f --- /dev/null +++ b/cli/templates/partials/features/auth/auth_logic.hbs @@ -0,0 +1,611 @@ +import 'package:{{flags.appSnake}}/src/imports/core_imports.dart'; +{{#if (or flags.isRiverpod flags.isBloc flags.isMobX flags.isGetX)}} +import 'package:{{flags.appSnake}}/src/imports/packages_imports.dart'; +{{else if (or flags.isProvider flags.isNoneState)}} +{{#if (or (eq flags.routerPackage "go_router") (eq flags.routerPackage "auto_route"))}} +import 'package:{{flags.appSnake}}/src/imports/packages_imports.dart'; +{{/if}} +{{/if}} + +{{#if flags.isRiverpod}} +import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}domain/repositories/{{else if (eq architecture "mvc")}}services/{{else if (eq architecture "mvvm")}}data/repositories/{{else}}features/auth/domain/repositories/{{/if}}auth_repository.dart'; +import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}data/repositories/{{else if (eq architecture "mvc")}}services/{{else if (eq architecture "mvvm")}}data/repositories/{{else}}features/auth/data/repositories/{{/if}}auth_repository_impl.dart'; + +// Provides the single instance of AuthRepositoryImpl +final authRepositoryProvider = Provider((ref) { + return AuthRepositoryImpl(); +}); + +final authControllerProvider = StateNotifierProvider((ref) { + return AuthController( + repository: ref.read(authRepositoryProvider), + ); +}); + +class AuthController extends StateNotifier { + final AuthRepository _repository; + + AuthController({ + required AuthRepository repository, + }) : _repository = repository, + super(false); // loading state is false + + void login({required BuildContext context, required String email, required String password}) async { + state = true; + + final result = await _repository.login(email: email, password: password); + + state = false; + result.fold( + (failure) { + if (context.mounted) { + showToast(context, message: failure.message, status: 'error'); + } + }, + (user) { + if (rootContext?.mounted ?? false) { + {{#if (eq flags.routerPackage "go_router")}} + rootContext!.go(AppRoutes.home); + {{else if (eq flags.routerPackage "auto_route")}} + rootContext!.replaceRoute(const HomeRoute()); + {{else}} + Navigator.pushReplacementNamed(rootContext!, AppRoutes.home); + {{/if}} + } + }, + ); + } + + void signUp({required BuildContext context, required String name, required String email, required String password}) async { + state = true; + + final result = await _repository.signUp(name: name, email: email, password: password); + + state = false; + result.fold( + (failure) { + if (context.mounted) { + showToast(context, message: failure.message, status: 'error'); + } + }, + (user) { + if (rootContext?.mounted ?? false) { + {{#if (eq flags.routerPackage "go_router")}} + rootContext!.go(AppRoutes.home); + {{else if (eq flags.routerPackage "auto_route")}} + rootContext!.replaceRoute(const HomeRoute()); + {{else}} + Navigator.pushReplacementNamed(rootContext!, AppRoutes.home); + {{/if}} + } + }, + ); + } + + void forgotPassword({required BuildContext context, required String email}) async { + state = true; + + final result = await _repository.forgotPassword(email: email); + + state = false; + result.fold( + (failure) { + if (context.mounted) { + showToast(context, message: failure.message, status: 'error'); + } + }, + (success) { + if (context.mounted) { + showToast(context, message: 'Password reset link sent successfully', status: 'success'); + } + if (context.mounted) { + {{#if (eq flags.routerPackage "go_router")}} + context.go(AppRoutes.login); + {{else if (eq flags.routerPackage "auto_route")}} + context.replaceRoute(const LoginRoute()); + {{else}} + Navigator.pushReplacementNamed(context, AppRoutes.login); + {{/if}} + } + }, + ); + } +} +{{else if flags.isBloc}} +import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}domain/repositories/{{else if (eq architecture "mvc")}}services/{{else if (eq architecture "mvvm")}}data/repositories/{{else}}features/auth/domain/repositories/{{/if}}auth_repository.dart'; + +class AuthBloc extends Bloc { + final AuthRepository _repository; + + AuthBloc({required AuthRepository repository}) : _repository = repository, super(const AuthState.initial()) { + on(_onLoginRequested); + on(_onSignUpRequested); + on(_onForgotPasswordRequested); + } + + Future _onLoginRequested( + LoginRequested event, + Emitter emit, + ) async { + emit(state.copyWith(isLoading: true)); + + final result = await _repository.login(email: event.email, password: event.password); + + result.fold( + (failure) { + emit(state.copyWith(isLoading: false)); + if (event.context.mounted) { + showToast(event.context, message: failure.message, status: 'error'); + } + }, + (user) { + emit(state.copyWith(isLoading: false)); + if (event.context.mounted) { + {{#if (eq flags.routerPackage "go_router")}} + event.context.go(AppRoutes.home); + {{else if (eq flags.routerPackage "auto_route")}} + event.context.replaceRoute(const HomeRoute()); + {{else}} + Navigator.pushReplacementNamed(event.context, AppRoutes.home); + {{/if}} + } + }, + ); + } + + Future _onSignUpRequested( + SignUpRequested event, + Emitter emit, + ) async { + emit(state.copyWith(isLoading: true)); + + final result = await _repository.signUp(name: event.name, email: event.email, password: event.password); + + result.fold( + (failure) { + emit(state.copyWith(isLoading: false)); + if (event.context.mounted) { + showToast(event.context, message: failure.message, status: 'error'); + } + }, + (user) { + emit(state.copyWith(isLoading: false)); + if (event.context.mounted) { + {{#if (eq flags.routerPackage "go_router")}} + event.context.go(AppRoutes.home); + {{else if (eq flags.routerPackage "auto_route")}} + event.context.replaceRoute(const HomeRoute()); + {{else}} + Navigator.pushReplacementNamed(event.context, AppRoutes.home); + {{/if}} + } + }, + ); + } + + Future _onForgotPasswordRequested( + ForgotPasswordRequested event, + Emitter emit, + ) async { + emit(state.copyWith(isLoading: true)); + + final result = await _repository.forgotPassword(email: event.email); + + result.fold( + (failure) { + emit(state.copyWith(isLoading: false)); + if (event.context.mounted) { + showToast(event.context, message: failure.message, status: 'error'); + } + }, + (success) { + emit(state.copyWith(isLoading: false)); + if (event.context.mounted) { + showToast(event.context, message: 'Password reset link sent successfully', status: 'success'); + } + if (event.context.mounted) { + {{#if (eq flags.routerPackage "go_router")}} + event.context.go(AppRoutes.login); + {{else if (eq flags.routerPackage "auto_route")}} + event.context.replaceRoute(const LoginRoute()); + {{else}} + Navigator.pushReplacementNamed(event.context, AppRoutes.login); + {{/if}} + } + }, + ); + } +} + +abstract class AuthEvent extends Equatable { + const AuthEvent(); + @override + List get props => []; +} + +class LoginRequested extends AuthEvent { + final BuildContext context; + final String email; + final String password; + const LoginRequested({required this.context, required this.email, required this.password}); +} + +class SignUpRequested extends AuthEvent { + final BuildContext context; + final String name; + final String email; + final String password; + const SignUpRequested({required this.context, required this.name, required this.email, required this.password}); +} + +class ForgotPasswordRequested extends AuthEvent { + final BuildContext context; + final String email; + const ForgotPasswordRequested({required this.context, required this.email}); +} + +class AuthState extends Equatable { + final bool isLoading; + const AuthState({required this.isLoading}); + const AuthState.initial() : isLoading = false; + AuthState copyWith({bool? isLoading}) { + return AuthState(isLoading: isLoading ?? this.isLoading); + } + @override + List get props => [isLoading]; +} +{{else if flags.isNoneState}} +import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}domain/repositories/{{else if (eq architecture "mvc")}}services/{{else if (eq architecture "mvvm")}}data/repositories/{{else}}features/auth/domain/repositories/{{/if}}auth_repository.dart'; + +class AuthViewModel extends ChangeNotifier { + final AuthRepository _repository; + + AuthViewModel({required AuthRepository repository}) : _repository = repository; + + bool _isLoading = false; + + bool get isLoading => _isLoading; + + void _setLoading(bool value) { + _isLoading = value; + notifyListeners(); + } + + void login({required BuildContext context, required String email, required String password}) async { + _setLoading(true); + + final result = await _repository.login(email: email, password: password); + + _setLoading(false); + result.fold( + (failure) { + if (context.mounted) { + showToast(context, message: failure.message, status: 'error'); + } + }, + (user) { + if (context.mounted) { + {{#if (eq flags.routerPackage "go_router")}} + context.go(AppRoutes.home); + {{else if (eq flags.routerPackage "auto_route")}} + context.replaceRoute(const HomeRoute()); + {{else}} + Navigator.pushReplacementNamed(context, AppRoutes.home); + {{/if}} + } + }, + ); + } + + void signUp({required BuildContext context, required String name, required String email, required String password}) async { + _setLoading(true); + + final result = await _repository.signUp(name: name, email: email, password: password); + + _setLoading(false); + result.fold( + (failure) { + if (context.mounted) { + showToast(context, message: failure.message, status: 'error'); + } + }, + (user) { + if (context.mounted) { + {{#if (eq flags.routerPackage "go_router")}} + context.go(AppRoutes.home); + {{else if (eq flags.routerPackage "auto_route")}} + context.replaceRoute(const HomeRoute()); + {{else}} + Navigator.pushReplacementNamed(context, AppRoutes.home); + {{/if}} + } + }, + ); + } + + void forgotPassword({required BuildContext context, required String email}) async { + _setLoading(true); + + final result = await _repository.forgotPassword(email: email); + + _setLoading(false); + result.fold( + (failure) { + if (context.mounted) { + showToast(context, message: failure.message, status: 'error'); + } + }, + (success) { + if (context.mounted) { + showToast(context, message: 'Password reset link sent successfully', status: 'success'); + } + }, + ); + } +} +{{else if flags.isProvider}} +import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}domain/repositories/{{else if (eq architecture "mvc")}}services/{{else if (eq architecture "mvvm")}}data/repositories/{{else}}features/auth/domain/repositories/{{/if}}auth_repository.dart'; + +class AuthProvider extends ChangeNotifier { + final AuthRepository _repository; + + AuthProvider({required AuthRepository repository}) : _repository = repository; + + bool _isLoading = false; + + bool get isLoading => _isLoading; + + void _setLoading(bool value) { + _isLoading = value; + notifyListeners(); + } + + void login({required BuildContext context, required String email, required String password}) async { + _setLoading(true); + + final result = await _repository.login(email: email, password: password); + + _setLoading(false); + result.fold( + (failure) { + if (context.mounted) { + showToast(context, message: failure.message, status: 'error'); + } + }, + (user) { + if (context.mounted) { + {{#if (eq flags.routerPackage "go_router")}} + context.go(AppRoutes.home); + {{else if (eq flags.routerPackage "auto_route")}} + context.replaceRoute(const HomeRoute()); + {{else}} + Navigator.pushReplacementNamed(context, AppRoutes.home); + {{/if}} + } + }, + ); + } + + void signUp({required BuildContext context, required String name, required String email, required String password}) async { + _setLoading(true); + + final result = await _repository.signUp(name: name, email: email, password: password); + + _setLoading(false); + result.fold( + (failure) { + if (context.mounted) { + showToast(context, message: failure.message, status: 'error'); + } + }, + (user) { + if (context.mounted) { + {{#if (eq flags.routerPackage "go_router")}} + context.go(AppRoutes.home); + {{else if (eq flags.routerPackage "auto_route")}} + context.replaceRoute(const HomeRoute()); + {{else}} + Navigator.pushReplacementNamed(context, AppRoutes.home); + {{/if}} + } + }, + ); + } + + void forgotPassword({required BuildContext context, required String email}) async { + _setLoading(true); + + final result = await _repository.forgotPassword(email: email); + + _setLoading(false); + result.fold( + (failure) { + if (context.mounted) { + showToast(context, message: failure.message, status: 'error'); + } + }, + (success) { + if (context.mounted) { + showToast(context, message: 'Password reset link sent successfully', status: 'success'); + } + if (context.mounted) { + {{#if (eq flags.routerPackage "go_router")}} + context.go(AppRoutes.login); + {{else if (eq flags.routerPackage "auto_route")}} + context.replaceRoute(const LoginRoute()); + {{else}} + Navigator.pushReplacementNamed(context, AppRoutes.login); + {{/if}} + } + }, + ); + } +} +{{else if flags.isGetX}} +import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}domain/repositories/{{else if (eq architecture "mvc")}}services/{{else if (eq architecture "mvvm")}}data/repositories/{{else}}features/auth/domain/repositories/{{/if}}auth_repository.dart'; + +class AuthController extends GetxController { + final AuthRepository _repository; + + AuthController({required AuthRepository repository}) : _repository = repository; + + final isLoading = false.obs; + + void login({required BuildContext context, required String email, required String password}) async { + isLoading.value = true; + + final result = await _repository.login(email: email, password: password); + + isLoading.value = false; + result.fold( + (failure) { + if (context.mounted) { + showToast(context, message: failure.message, status: 'error'); + } + }, + (user) { + Get.offAllNamed(AppRoutes.home); + }, + ); + } + + void signUp({required BuildContext context, required String name, required String email, required String password}) async { + isLoading.value = true; + + final result = await _repository.signUp(name: name, email: email, password: password); + + isLoading.value = false; + result.fold( + (failure) { + if (context.mounted) { + showToast(context, message: failure.message, status: 'error'); + } + }, + (user) { + Get.offAllNamed(AppRoutes.home); + }, + ); + } + + void forgotPassword({required BuildContext context, required String email}) async { + isLoading.value = true; + + final result = await _repository.forgotPassword(email: email); + + isLoading.value = false; + result.fold( + (failure) { + if (context.mounted) { + showToast(context, message: failure.message, status: 'error'); + } + }, + (success) { + if (context.mounted) { + showToast(context, message: 'Password reset link sent successfully', status: 'success'); + } + Get.offNamed(AppRoutes.login); + }, + ); + } +} +{{else if flags.isMobX}} +import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}domain/repositories/{{else if (eq architecture "mvc")}}services/{{else if (eq architecture "mvvm")}}data/repositories/{{else}}features/auth/domain/repositories/{{/if}}auth_repository.dart'; + +part 'auth_store.g.dart'; + +class AuthStore = AuthStoreBase with _$AuthStore; + +abstract class AuthStoreBase with Store { + final AuthRepository _repository; + + AuthStoreBase({required AuthRepository repository}) : _repository = repository; + + @observable + bool isLoading = false; + + @action + void login({required BuildContext context, required String email, required String password}) async { + isLoading = true; + + final result = await _repository.login(email: email, password: password); + + isLoading = false; + result.fold( + (failure) { + if (context.mounted) { + showToast(context, message: failure.message, status: 'error'); + } + }, + (user) { + if (context.mounted) { + {{#if (eq flags.routerPackage "go_router")}} + context.go(AppRoutes.home); + {{else if (eq flags.routerPackage "auto_route")}} + context.replaceRoute(const HomeRoute()); + {{else}} + Navigator.pushReplacementNamed(context, AppRoutes.home); + {{/if}} + } + }, + ); + } + + @action + void signUp({required BuildContext context, required String name, required String email, required String password}) async { + isLoading = true; + + final result = await _repository.signUp(name: name, email: email, password: password); + + isLoading = false; + result.fold( + (failure) { + if (context.mounted) { + showToast(context, message: failure.message, status: 'error'); + } + }, + (user) { + if (context.mounted) { + {{#if (eq flags.routerPackage "go_router")}} + context.go(AppRoutes.home); + {{else if (eq flags.routerPackage "auto_route")}} + context.replaceRoute(const HomeRoute()); + {{else}} + Navigator.pushReplacementNamed(context, AppRoutes.home); + {{/if}} + } + }, + ); + } + + @action + void forgotPassword({required BuildContext context, required String email}) async { + isLoading = true; + + final result = await _repository.forgotPassword(email: email); + + isLoading = false; + result.fold( + (failure) { + if (context.mounted) { + showToast(context, message: failure.message, status: 'error'); + } + }, + (success) { + if (context.mounted) { + showToast(context, message: 'Password reset link sent successfully', status: 'success'); + } + if (context.mounted) { + {{#if (eq flags.routerPackage "go_router")}} + context.go(AppRoutes.login); + {{else if (eq flags.routerPackage "auto_route")}} + context.replaceRoute(const LoginRoute()); + {{else}} + Navigator.pushReplacementNamed(context, AppRoutes.login); + {{/if}} + } + }, + ); + } +} +{{/if}} + diff --git a/cli/templates/partials/features/auth/auth_repository.hbs b/cli/templates/partials/features/auth/auth_repository.hbs new file mode 100644 index 0000000..48cd526 --- /dev/null +++ b/cli/templates/partials/features/auth/auth_repository.hbs @@ -0,0 +1,32 @@ +import 'package:{{flags.appSnake}}/src/utils/utils.dart'; +import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}domain/entities/user.dart{{else if (eq architecture "mvc")}}models/user_model.dart{{else if (eq architecture "mvvm")}}data/models/user_model.dart{{else}}features/auth/domain/entities/user.dart{{/if}}'; + +abstract class AuthRepository { + /// Stream of auth state changes. Emits AppUser when authenticated, null when not. + Stream get onAuthStateChanged; + + /// Sign in with email and password + FutureEither login({ + required String email, + required String password, + }); + + /// Sign up with email, password, and optional name + FutureEither signUp({ + required String name, + required String email, + required String password, + }); + + /// Send a password reset email + FutureEither forgotPassword({ + required String email, + }); + + /// Sign out the current user + FutureEither logout(); + + /// Check if the user is currently authenticated natively + FutureEither checkAuthState(); +} + diff --git a/cli/templates/partials/features/auth/auth_repository_impl.hbs b/cli/templates/partials/features/auth/auth_repository_impl.hbs new file mode 100644 index 0000000..d344a5c --- /dev/null +++ b/cli/templates/partials/features/auth/auth_repository_impl.hbs @@ -0,0 +1,116 @@ +import 'package:{{flags.appSnake}}/src/imports/core_imports.dart'; +import 'package:{{flags.appSnake}}/src/imports/packages_imports.dart'; + +import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}domain/entities/user.dart{{else if (eq architecture "mvc")}}models/user_model.dart{{else if (eq architecture "mvvm")}}data/models/user_model.dart{{else}}features/auth/domain/entities/user.dart{{/if}}'; +import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}domain/repositories/{{else if (eq architecture "mvc")}}services/{{else if (eq architecture "mvvm")}}data/repositories/{{else}}features/auth/domain/repositories/{{/if}}auth_repository.dart'; + +class AuthRepositoryImpl implements AuthRepository { + final AuthService _authService = AuthService.instance; + + @override + Stream get onAuthStateChanged { + return _authService.authStateChanges.map((userData) { + if (userData == null) return null; + return AppUser( + id: userData['id'] ?? '', + email: userData['email'] ?? '', + name: userData['name'], + photoUrl: userData['photoUrl'], + ); + }); + } + + @override + FutureEither login({ + required String email, + required String password, + }) async { + final result = await _authService.login(email: email, password: password); + + return result.flatMap((userData) { + if (userData == null) { + return left(const ServerFailure('Login failed: User record not found')); + } + + {{#if (or (eq backend.provider "firebase") (eq backend.provider "supabase"))}} + final user = AppUser( + id: userData['id'], + email: userData['email'] ?? email, + name: userData['name'], + photoUrl: userData['photoUrl'], + ); + {{else}} + final data = userData['user'] ?? userData; + final user = AppUser( + id: data['id'].toString(), + email: data['email'] ?? email, + name: data['name'], + ); + {{/if}} + + return right(user); + }); + } + + @override + FutureEither signUp({ + required String name, + required String email, + required String password, + }) async { + final result = await _authService.signUp( + name: name, + email: email, + password: password, + ); + + return result.flatMap((userData) { + if (userData == null) { + return left(const ServerFailure('Sign up failed: User record corrupted')); + } + + {{#if (or (eq backend.provider "firebase") (eq backend.provider "supabase"))}} + final user = AppUser( + id: userData['id'], + email: userData['email'] ?? email, + name: name, + ); + {{else}} + final data = userData['user'] ?? userData; + final user = AppUser( + id: data['id'].toString(), + email: data['email'] ?? email, + name: name, + ); + {{/if}} + + return right(user); + }); + } + + @override + FutureEither forgotPassword({required String email}) { + return _authService.forgotPassword(email: email); + } + + @override + FutureEither logout() { + return _authService.logout(); + } + + @override + FutureEither checkAuthState() async { + final result = await _authService.getCurrentUser(); + + return result.map((userData) { + if (userData == null) return null; + + return AppUser( + id: userData['id'], + email: userData['email'] ?? '', + name: userData['name'], + photoUrl: userData['photoUrl'], + ); + }); + } +} diff --git a/cli/templates/partials/features/auth/forgot_password_screen.hbs b/cli/templates/partials/features/auth/forgot_password_screen.hbs new file mode 100644 index 0000000..992af62 --- /dev/null +++ b/cli/templates/partials/features/auth/forgot_password_screen.hbs @@ -0,0 +1,368 @@ +import 'package:{{flags.appSnake}}/src/imports/core_imports.dart'; +import 'package:{{flags.appSnake}}/src/imports/packages_imports.dart'; + +{{#if flags.isRiverpod}} +import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}presentation/providers/{{else if (eq architecture "mvc")}}controllers/auth/{{else if (eq architecture "mvvm")}}ui/auth/providers/{{else}}features/auth/presentation/providers/{{/if}}auth_provider.dart'; +{{else if flags.isBloc}} +import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}presentation/providers/{{else if (eq architecture "mvc")}}controllers/auth/{{else if (eq architecture "mvvm")}}ui/auth/bloc/{{else}}features/auth/presentation/providers/{{/if}}auth_bloc.dart'; +{{else if flags.isProvider}} +import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}presentation/providers/{{else if (eq architecture "mvc")}}controllers/auth/{{else if (eq architecture "mvvm")}}ui/auth/providers/{{else}}features/auth/presentation/providers/{{/if}}auth_provider.dart'; +{{else if flags.isGetX}} +import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}presentation/controllers/auth/{{else if (eq architecture "mvc")}}controllers/auth/{{else if (eq architecture "mvvm")}}ui/auth/controllers/{{else}}features/auth/presentation/controllers/{{/if}}auth_controller.dart'; +{{else if flags.isMobX}} +{{#if flags.usesFlutterHooks}} +import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}presentation/providers/{{else if (eq architecture "mvc")}}controllers/auth/{{else if (eq architecture "mvvm")}}ui/auth/stores/{{else}}features/auth/presentation/providers/{{/if}}auth_store.dart'; +{{/if}} +{{else}} +{{#if flags.usesFlutterHooks}} +import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}presentation/providers/{{else if (eq architecture "mvc")}}controllers/auth/{{else if (eq architecture "mvvm")}}ui/auth/view_models/{{else}}features/auth/presentation/providers/{{/if}}auth_view_model.dart'; +{{/if}} +{{/if}} + +{{#if (eq flags.routerPackage "auto_route")}} +@RoutePage() +{{/if}} +{{#if flags.usesFlutterHooks}} +class ForgotPasswordScreen extends {{#if flags.isRiverpod}}HookConsumerWidget{{else}}HookWidget{{/if}} { + const ForgotPasswordScreen({super.key}); + + @override + Widget build(BuildContext context, {{#if flags.isRiverpod}}WidgetRef ref{{/if}}) { + final formKey = useMemoized(() => GlobalKey()); + final emailController = useTextEditingController(); + + {{#if flags.isRiverpod}} + final isLoading = ref.watch(authControllerProvider); + {{else if flags.isBloc}} + final isLoading = context.select((AuthBloc bloc) => bloc.state.isLoading); + {{else if flags.isProvider}} + final isLoading = context.select((AuthProvider p) => p.isLoading); + {{else if flags.isGetX}} + final controller = Get.find(); + final isLoading = controller.isLoading.value; + {{else if flags.isMobX}} + final authStore = useMemoized(() => AuthStore(repository: AuthRepositoryImpl()), []); + final isLoading = authStore.isLoading; + {{else if flags.isNoneState}} + final viewModel = useMemoized(() => AuthViewModel(repository: AuthRepositoryImpl()), []); + useListenable(viewModel); + final isLoading = viewModel.isLoading; + {{else}} + final isLoadingState = useState(false); + final isLoading = isLoadingState.value; + {{/if}} + + final cs = context.theme.colorScheme; + final tt = context.theme.textTheme; + + Future handleForgotPassword() async { + if (!(formKey.currentState?.validate() ?? false)) return; + + {{#if flags.isRiverpod}} + ref.read(authControllerProvider.notifier).forgotPassword( + context: context, + email: emailController.text, + ); + {{else if flags.isBloc}} + context.read().add( + ForgotPasswordRequested( + context: context, + email: emailController.text, + ), + ); + {{else if flags.isProvider}} + context.read().forgotPassword( + context: context, + email: emailController.text, + ); + {{else if flags.isGetX}} + controller.forgotPassword( + context: context, + email: emailController.text, + ); + {{else if flags.isMobX}} + authStore.forgotPassword( + context: context, + email: emailController.text.trim(), + ); + {{else if flags.isNoneState}} + viewModel.forgotPassword( + context: context, + email: emailController.text.trim(), + ); + {{else}} + isLoadingState.value = true; + try { + final result = await AuthService.instance.forgotPassword( + email: emailController.text.trim(), + ); + + result.fold( + (failure) { + if (context.mounted) { + showToast(context, message: failure.message, status: 'error'); + } + }, + (success) { + if (context.mounted) { + {{#if (eq flags.routerPackage "go_router")}} + context.go(AppRoutes.login); + {{else if (eq flags.routerPackage "auto_route")}} + context.replaceRoute(const LoginRoute()); + {{else}} + Navigator.pushReplacementNamed(context, AppRoutes.login); + {{/if}} + } + }, + ); + } catch (e) { + if (context.mounted) { + showToast(context, message: e.toString(), status: 'error'); + } + } finally { + isLoadingState.value = false; + } + {{/if}} + } + + {{#if flags.isGetX}} + return Obx(() => _ForgotPasswordView( + {{else}} + return _ForgotPasswordView( + {{/if}} + formKey: formKey, + emailController: emailController, + isLoading: isLoading, + onForgotPassword: handleForgotPassword, + cs: cs, + tt: tt, + ); + {{#if flags.isGetX}} + ); + {{/if}} + } +} +{{else}} +class ForgotPasswordScreen extends {{#if flags.isRiverpod}}ConsumerStatefulWidget{{else}}StatefulWidget{{/if}} { + const ForgotPasswordScreen({super.key}); + + @override + {{#if flags.isRiverpod}} + ConsumerState createState() => _ForgotPasswordScreenState(); + {{else}} + State createState() => _ForgotPasswordScreenState(); + {{/if}} +} + +class _ForgotPasswordScreenState extends {{#if flags.isRiverpod}}ConsumerState{{else}}State{{/if}} { + final _formKey = GlobalKey(); + final _emailController = TextEditingController(); + {{#if (or flags.isNoneState flags.isMobX)}} + bool _isLoading = false; + {{/if}} + + @override + void dispose() { + _emailController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + {{#if flags.isRiverpod}} + final isLoading = ref.watch(authControllerProvider); + {{else if flags.isBloc}} + final isLoading = context.select((AuthBloc bloc) => bloc.state.isLoading); + {{else if flags.isProvider}} + final isLoading = context.select((AuthProvider p) => p.isLoading); + {{else if flags.isGetX}} + final controller = Get.find(); + {{else if flags.isMobX}} + final isLoading = _isLoading; + {{else}} + final isLoading = _isLoading; + {{/if}} + + final cs = context.theme.colorScheme; + final tt = context.theme.textTheme; + + Future handleForgotPassword() async { + if (!(_formKey.currentState?.validate() ?? false)) return; + + {{#if (or flags.isNoneState flags.isMobX)}} + setState(() => _isLoading = true); + {{/if}} + + {{#if flags.isRiverpod}} + ref.read(authControllerProvider.notifier).forgotPassword( + context: context, + email: _emailController.text, + ); + {{else if flags.isBloc}} + context.read().add( + ForgotPasswordRequested( + context: context, + email: _emailController.text, + ), + ); + {{else if flags.isProvider}} + context.read().forgotPassword( + context: context, + email: _emailController.text, + ); + {{else if flags.isGetX}} + controller.forgotPassword( + context: context, + email: _emailController.text, + ); + {{else}} + try { + final result = await AuthService.instance.forgotPassword( + email: _emailController.text.trim(), + ); + + result.fold( + (failure) { + if (context.mounted) { + showToast(context, message: failure.message, status: 'error'); + } + }, + (success) { + if (context.mounted) { + {{#if (eq flags.routerPackage "go_router")}} + context.go(AppRoutes.login); + {{else if (eq flags.routerPackage "auto_route")}} + context.replaceRoute(const LoginRoute()); + {{else}} + Navigator.pushReplacementNamed(context, AppRoutes.login); + {{/if}} + } + }, + ); + } catch (e) { + if (context.mounted) { + showToast(context, message: e.toString(), status: 'error'); + } + } finally { + if (context.mounted) setState(() => _isLoading = false); + } + {{/if}} + } + + {{#if flags.isGetX}} + return Obx(() { + final isLoading = controller.isLoading.value; + return _ForgotPasswordView( + formKey: _formKey, + emailController: _emailController, + isLoading: isLoading, + onForgotPassword: handleForgotPassword, + cs: cs, + tt: tt, + ); + }); + {{else}} + return _ForgotPasswordView( + formKey: _formKey, + emailController: _emailController, + isLoading: isLoading, + onForgotPassword: handleForgotPassword, + cs: cs, + tt: tt, + ); + {{/if}} + } +} +{{/if}} + +class _ForgotPasswordView extends StatelessWidget { + const _ForgotPasswordView({ + required this.formKey, + required this.emailController, + required this.isLoading, + required this.onForgotPassword, + required this.cs, + required this.tt, + }); + + final GlobalKey formKey; + final TextEditingController emailController; + final bool isLoading; + final VoidCallback onForgotPassword; + final ColorScheme cs; + final TextTheme tt; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: const AppTopBar(title: ''), + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: EdgeInsets.symmetric(horizontal: {{res 'AppSpacing.lg' 'w' flags.usesScreenutil}}), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox(height: {{res 'AppSpacing.xl' 'h' flags.usesScreenutil}}), + Text( + {{#if flags.supportsLocalization}}'auth.forgot_password_title'.tr(){{else}}'Reset Password'{{/if}}, + style: tt.headlineMedium?.copyWith(fontWeight: FontWeight.bold), + ), + SizedBox(height: {{res 'AppSpacing.sm' 'h' flags.usesScreenutil}}), + Text( + {{#if flags.supportsLocalization}}'auth.forgot_password_subtitle'.tr(){{else}}'Enter your email to receive a reset link'{{/if}}, + textAlign: TextAlign.center, + style: tt.bodyMedium?.copyWith(color: cs.onSurfaceVariant), + ), + SizedBox(height: {{res 'AppSpacing.xxxl' 'h' flags.usesScreenutil}}), + Form( + key: formKey, + child: Column( + children: [ + AppTextField( + controller: emailController, + enabled: !isLoading, + keyboardType: TextInputType.emailAddress, + label: {{#if flags.supportsLocalization}}'auth.email'.tr(){{else}}'Email'{{/if}}, + prefixIcon: const Icon({{#if flags.usesIconsaxPlus}}IconsaxPlusBold.sms{{else}}Icons.email_outlined{{/if}}), + validator: (v) { + if (AppUtils.isBlank(v)) { + return {{#if flags.supportsLocalization}}'auth.email_required'.tr(){{else}}'Email is required'{{/if}}; + } + if (!AppUtils.isValidEmail(v!)) { + return {{#if flags.supportsLocalization}}'auth.email_invalid'.tr(){{else}}'Enter a valid email'{{/if}}; + } + return null; + }, + ), + SizedBox(height: {{res 'AppSpacing.lg' 'h' flags.usesScreenutil}}), + AppButton( + label: 'Send Reset Link', + isLoading: isLoading, + onPressed: isLoading ? null : onForgotPassword, + width: ButtonSize.large, + isFullWidth: false, + ), + ], + ), + ), + SizedBox(height: {{res 'AppSpacing.xxxl' 'h' flags.usesScreenutil}}), + TextButton( + onPressed: () => Navigator.pop(context), + child: Text( + {{#if flags.supportsLocalization}}'auth.back_to_login'.tr(){{else}}'Back to Login'{{/if}}, + style: tt.labelLarge?.copyWith( + color: cs.primary, + fontWeight: FontWeight.bold, + ), + ), + ), + SizedBox(height: {{res 'AppSpacing.xl' 'h' flags.usesScreenutil}}), + ], + ), + ), + ), + ), + ); + } +} diff --git a/cli/templates/partials/features/auth/login_screen.hbs b/cli/templates/partials/features/auth/login_screen.hbs new file mode 100644 index 0000000..4798756 --- /dev/null +++ b/cli/templates/partials/features/auth/login_screen.hbs @@ -0,0 +1,540 @@ +import 'package:{{flags.appSnake}}/src/imports/core_imports.dart'; +import 'package:{{flags.appSnake}}/src/imports/packages_imports.dart'; + +{{#if flags.isRiverpod}} +import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}presentation/providers/{{else if (eq architecture "mvc")}}controllers/auth/{{else if (eq architecture "mvvm")}}ui/auth/providers/{{else}}features/auth/presentation/providers/{{/if}}auth_provider.dart'; +{{else if flags.isBloc}} +import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}presentation/providers/{{else if (eq architecture "mvc")}}controllers/auth/{{else if (eq architecture "mvvm")}}ui/auth/bloc/{{else}}features/auth/presentation/providers/{{/if}}auth_bloc.dart'; +{{else if flags.isProvider}} +import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}presentation/providers/{{else if (eq architecture "mvc")}}controllers/auth/{{else if (eq architecture "mvvm")}}ui/auth/providers/{{else}}features/auth/presentation/providers/{{/if}}auth_provider.dart'; +{{else if flags.isGetX}} +import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}presentation/controllers/auth/{{else if (eq architecture "mvc")}}controllers/auth/{{else if (eq architecture "mvvm")}}ui/auth/controllers/{{else}}features/auth/presentation/controllers/{{/if}}auth_controller.dart'; +{{else if flags.isMobX}} +{{#if flags.usesFlutterHooks}} +import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}presentation/providers/{{else if (eq architecture "mvc")}}controllers/auth/{{else if (eq architecture "mvvm")}}ui/auth/stores/{{else}}features/auth/presentation/providers/{{/if}}auth_store.dart'; +{{/if}} +{{else}} +{{#if flags.usesFlutterHooks}} +import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}presentation/providers/{{else if (eq architecture "mvc")}}controllers/auth/{{else if (eq architecture "mvvm")}}ui/auth/view_models/{{else}}features/auth/presentation/providers/{{/if}}auth_view_model.dart'; +{{/if}} +{{/if}} + +{{#if (eq flags.routerPackage "auto_route")}} +@RoutePage() +{{/if}} +{{#if flags.usesFlutterHooks}} +class LoginScreen extends {{#if flags.isRiverpod}}HookConsumerWidget{{else}}HookWidget{{/if}} { + const LoginScreen({super.key}); + + @override + Widget build(BuildContext context, {{#if flags.isRiverpod}}WidgetRef ref{{/if}}) { + final formKey = useMemoized(() => GlobalKey()); + final emailController = useTextEditingController(); + final passwordController = useTextEditingController(); + final obscurePassword = useState(true); + + {{#if flags.isRiverpod}} + final isLoading = ref.watch(authControllerProvider); + {{else if flags.isBloc}} + final isLoading = context.select((AuthBloc bloc) => bloc.state.isLoading); + {{else if flags.isProvider}} + final isLoading = context.select((AuthProvider p) => p.isLoading); + {{else if flags.isGetX}} + final controller = Get.find(); + final isLoading = controller.isLoading.value; + {{else if flags.isMobX}} + final authStore = useMemoized(() => AuthStore(repository: AuthRepositoryImpl()), []); + final isLoading = authStore.isLoading; + {{else if flags.isNoneState}} + final viewModel = useMemoized(() => AuthViewModel(repository: AuthRepositoryImpl()), []); + useListenable(viewModel); + final isLoading = viewModel.isLoading; + {{else}} + final isLoadingState = useState(false); + final isLoading = isLoadingState.value; + {{/if}} + + final cs = context.theme.colorScheme; + final tt = context.theme.textTheme; + + Future handleLogin() async { + if (!(formKey.currentState?.validate() ?? false)) return; + + {{#if flags.isRiverpod}} + ref.read(authControllerProvider.notifier).login( + context: context, + email: emailController.text, + password: passwordController.text, + ); + {{else if flags.isBloc}} + context.read().add( + LoginRequested( + context: context, + email: emailController.text, + password: passwordController.text, + ), + ); + {{else if flags.isProvider}} + context.read().login( + context: context, + email: emailController.text, + password: passwordController.text, + ); + {{else if flags.isGetX}} + controller.login( + context: context, + email: emailController.text, + password: passwordController.text, + ); + {{else if flags.isMobX}} + authStore.login( + context: context, + email: emailController.text.trim(), + password: passwordController.text.trim(), + ); + {{else if flags.isNoneState}} + viewModel.login( + context: context, + email: emailController.text.trim(), + password: passwordController.text.trim(), + ); + {{else}} + isLoadingState.value = true; + try { + final result = await AuthService.instance.login( + email: emailController.text.trim(), + password: passwordController.text.trim(), + ); + + result.fold( + (failure) { + if (context.mounted) { + showToast(context, message: failure.message, status: 'error'); + } + }, + (user) { + if (context.mounted) { + {{#if (eq flags.routerPackage "go_router")}} + context.go(AppRoutes.home); + {{else if (eq flags.routerPackage "auto_route")}} + context.replaceRoute(const HomeRoute()); + {{else}} + Navigator.pushReplacementNamed(context, AppRoutes.home); + {{/if}} + } + }, + ); + } catch (e) { + if (context.mounted) { + showToast(context, message: e.toString(), status: 'error'); + } + } finally { + isLoadingState.value = false; + } + {{/if}} + } + + {{#if flags.isGetX}} + return Obx(() => _LoginView( + {{else}} + return _LoginView( + {{/if}} + formKey: formKey, + emailController: emailController, + passwordController: passwordController, + obscurePassword: obscurePassword.value, + isLoading: isLoading, + onToggleObscure: () => obscurePassword.value = !obscurePassword.value, + onLogin: handleLogin, + cs: cs, + tt: tt, + ); + {{#if flags.isGetX}} + ); + {{/if}} + } +} +{{else}} +class LoginScreen extends {{#if flags.isRiverpod}}ConsumerStatefulWidget{{else}}StatefulWidget{{/if}} { + const LoginScreen({super.key}); + + @override + {{#if flags.isRiverpod}} + ConsumerState createState() => _LoginScreenState(); + {{else}} + State createState() => _LoginScreenState(); + {{/if}} +} + +class _LoginScreenState extends {{#if flags.isRiverpod}}ConsumerState{{else}}State{{/if}} { + final _formKey = GlobalKey(); + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + bool _obscurePassword = true; + {{#if (or flags.isNoneState flags.isMobX)}} + bool _isLoading = false; + {{/if}} + + @override + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + {{#if flags.isRiverpod}} + final isLoading = ref.watch(authControllerProvider); + {{else if flags.isBloc}} + final isLoading = context.select((AuthBloc bloc) => bloc.state.isLoading); + {{else if flags.isProvider}} + final isLoading = context.select((AuthProvider p) => p.isLoading); + {{else if flags.isGetX}} + final controller = Get.find(); + {{else if flags.isMobX}} + final isLoading = _isLoading; + {{else}} + final isLoading = _isLoading; + {{/if}} + + final cs = context.theme.colorScheme; + final tt = context.theme.textTheme; + + Future handleLogin() async { + if (!(_formKey.currentState?.validate() ?? false)) return; + + {{#if (or flags.isNoneState flags.isMobX)}} + setState(() => _isLoading = true); + {{/if}} + + {{#if flags.isRiverpod}} + ref.read(authControllerProvider.notifier).login( + context: context, + email: _emailController.text, + password: _passwordController.text, + ); + {{else if flags.isBloc}} + context.read().add( + LoginRequested( + context: context, + email: _emailController.text, + password: _passwordController.text, + ), + ); + {{else if flags.isProvider}} + context.read().login( + context: context, + email: _emailController.text, + password: _passwordController.text, + ); + {{else if flags.isGetX}} + controller.login( + context: context, + email: _emailController.text, + password: _passwordController.text, + ); + {{else}} + try { + final result = await AuthService.instance.login( + email: _emailController.text.trim(), + password: _passwordController.text.trim(), + ); + + result.fold( + (failure) { + if (context.mounted) { + showToast(context, message: failure.message, status: 'error'); + } + }, + (user) { + if (context.mounted) { + {{#if (eq flags.routerPackage "go_router")}} + context.go(AppRoutes.home); + {{else if (eq flags.routerPackage "auto_route")}} + context.replaceRoute(const HomeRoute()); + {{else}} + Navigator.pushReplacementNamed(context, AppRoutes.home); + {{/if}} + } + }, + ); + } catch (e) { + if (context.mounted) { + showToast(context, message: e.toString(), status: 'error'); + } + } finally { + if (context.mounted) setState(() => _isLoading = false); + } + {{/if}} + } + + {{#if flags.isGetX}} + return Obx(() { + final isLoading = controller.isLoading.value; + return _LoginView( + formKey: _formKey, + emailController: _emailController, + passwordController: _passwordController, + obscurePassword: _obscurePassword, + isLoading: isLoading, + onToggleObscure: () => setState(() => _obscurePassword = !_obscurePassword), + onLogin: handleLogin, + cs: cs, + tt: tt, + ); + }); + {{else}} + return _LoginView( + formKey: _formKey, + emailController: _emailController, + passwordController: _passwordController, + obscurePassword: _obscurePassword, + isLoading: isLoading, + onToggleObscure: () => setState(() => _obscurePassword = !_obscurePassword), + onLogin: handleLogin, + cs: cs, + tt: tt, + ); + {{/if}} + } +} +{{/if}} + +class _LoginView extends StatelessWidget { + const _LoginView({ + required this.formKey, + required this.emailController, + required this.passwordController, + required this.obscurePassword, + required this.isLoading, + required this.onToggleObscure, + required this.onLogin, + required this.cs, + required this.tt, + }); + + final GlobalKey formKey; + final TextEditingController emailController; + final TextEditingController passwordController; + final bool obscurePassword; + final bool isLoading; + final VoidCallback onToggleObscure; + final VoidCallback onLogin; + final ColorScheme cs; + final TextTheme tt; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: EdgeInsets.symmetric(horizontal: {{res 'AppSpacing.lg' 'w' flags.usesScreenutil}}), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox(height: {{res 'AppSpacing.xl' 'h' flags.usesScreenutil}}), + Text( + {{#if flags.supportsLocalization}}'auth.log_in'.tr(){{else}}'Welcome Back'{{/if}}, + style: tt.headlineMedium?.copyWith(fontWeight: FontWeight.bold), + ), + SizedBox(height: {{res 'AppSpacing.sm' 'h' flags.usesScreenutil}}), + Text( + {{#if flags.supportsLocalization}}'auth.log_in_subtitle'.tr(){{else}}'Log in to continue your journey'{{/if}}, + textAlign: TextAlign.center, + style: tt.bodyMedium?.copyWith(color: cs.onSurfaceVariant), + ), + SizedBox(height: {{res 'AppSpacing.xxxl' 'h' flags.usesScreenutil}}), + // Form Card + Form( + key: formKey, + child: Column( + children: [ + AppTextField( + controller: emailController, + enabled: !isLoading, + label: {{#if flags.supportsLocalization}}'auth.email'.tr(){{else}}'Email'{{/if}}, + prefixIcon: const Icon({{#if flags.usesIconsaxPlus}}IconsaxPlusBold.sms{{else}}Icons.email_outlined{{/if}}), + validator: (v) { + if (AppUtils.isBlank(v)) { + return {{#if flags.supportsLocalization}}'auth.email_required'.tr(){{else}}'Email is required'{{/if}}; + } + if (!AppUtils.isValidEmail(v!)) { + return {{#if flags.supportsLocalization}}'auth.email_invalid'.tr(){{else}}'Enter a valid email'{{/if}}; + } + return null; + }, + ), + SizedBox(height: {{res 'AppSpacing.md' 'h' flags.usesScreenutil}}), + AppTextField( + controller: passwordController, + enabled: !isLoading, + label: {{#if flags.supportsLocalization}}'auth.password'.tr(){{else}}'Password'{{/if}}, + obscureText: obscurePassword, + prefixIcon: const Icon({{#if flags.usesIconsaxPlus}}IconsaxPlusBold.lock{{else}}Icons.lock_outline{{/if}}), + suffixIcon: IconButton( + icon: Icon(obscurePassword ? Icons.visibility_off : Icons.visibility), + onPressed: onToggleObscure, + ), + validator: (v) { + if (AppUtils.isBlank(v)) { + return {{#if flags.supportsLocalization}}'auth.password_required'.tr(){{else}}'Password is required'{{/if}}; + } + if (v!.length < 6) { + return {{#if flags.supportsLocalization}}'auth.password_too_short'.tr(){{else}}'Password must be at least 6 characters'{{/if}}; + } + return null; + }, + ), + SizedBox(height: {{res 'AppSpacing.sm' 'h' flags.usesScreenutil}}), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + spacing: {{res 5 'w' flags.usesScreenutil}}, + children: [ + SizedBox( + width: {{res 20 'w' flags.usesScreenutil}}, + height: {{res 20 'h' flags.usesScreenutil}}, + child: Checkbox( + value: true, + onChanged: (value) {}, + ), + ), + Text( + {{#if flags.supportsLocalization}}'auth.remember_me'.tr(){{else}}'Remember Me'{{/if}}, + style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant), + ), + ], + ), + TextButton( + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + ), + onPressed: () { + {{#if (eq flags.routerPackage "go_router")}} + context.push(AppRoutes.forgotPassword); + {{else if (eq flags.routerPackage "auto_route")}} + context.pushRoute(const ForgotPasswordRoute()); + {{else if flags.isGetX}} + Get.toNamed(AppRoutes.forgotPassword); + {{else}} + Navigator.pushNamed(context, AppRoutes.forgotPassword); + {{/if}} + }, + child: Text( + {{#if flags.supportsLocalization}}'auth.forgot_password'.tr(){{else}}'Forgot Password?'{{/if}}, + style: tt.bodySmall?.copyWith( + color: cs.onSurfaceVariant, + ), + ), + ), + ], + ), + SizedBox(height: {{res 'AppSpacing.lg' 'h' flags.usesScreenutil}}), + AppButton( + label: 'Sign In', + isLoading: isLoading, + onPressed: isLoading ? null : onLogin, + width: ButtonSize.large, + isFullWidth: false, + ), + ], + ), + ), + SizedBox(height: {{res 'AppSpacing.xxxl' 'h' flags.usesScreenutil}}), + {{#if flags.usesFlutterSvg}} + Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + spacing: {{res 20 'w' flags.usesScreenutil}}, + children: [ + SizedBox( + width: {{res 50 'w' flags.usesScreenutil}}, + height: {{res 50 'w' flags.usesScreenutil}}, + child: TextButton( + onPressed: () {}, + style: TextButton.styleFrom( + backgroundColor: const Color(0xFFEA4335).withValues(alpha: 0.8), + padding: EdgeInsets.symmetric(horizontal: {{res 10 'w' flags.usesScreenutil}}), + shape: const RoundedRectangleBorder( + borderRadius: AppBorders.button, + ), + ), + child: SvgPicture.asset(AppAssets.googleIcon), + ), + ), + SizedBox( + width: {{res 50 'w' flags.usesScreenutil}}, + height: {{res 50 'w' flags.usesScreenutil}}, + child: TextButton( + onPressed: () {}, + style: TextButton.styleFrom( + backgroundColor: const Color(0xFF4285F4), + padding: EdgeInsets.symmetric(horizontal: {{res 10 'w' flags.usesScreenutil}}), + shape: const RoundedRectangleBorder( + borderRadius: AppBorders.button, + ), + ), + child: SvgPicture.asset(AppAssets.facebookIcon), + ), + ), + SizedBox( + width: {{res 50 'w' flags.usesScreenutil}}, + height: {{res 50 'w' flags.usesScreenutil}}, + child: TextButton( + onPressed: () {}, + style: TextButton.styleFrom( + backgroundColor: const Color(0xFF000000), + padding: EdgeInsets.symmetric(horizontal: {{res 10 'w' flags.usesScreenutil}}), + shape: const RoundedRectangleBorder( + borderRadius: AppBorders.button, + ), + ), + child: SvgPicture.asset(AppAssets.appleIcon), + ), + ), + ], + ), + SizedBox(height: {{res 'AppSpacing.xl' 'h' flags.usesScreenutil}}), + ], + ), + {{/if}} + InkWell( + onTap: () { + {{#if (eq flags.routerPackage "go_router")}} + context.push(AppRoutes.signup); + {{else if (eq flags.routerPackage "auto_route")}} + context.pushRoute(const SignupRoute()); + {{else if flags.isGetX}} + Get.toNamed(AppRoutes.signup); + {{else}} + Navigator.pushNamed(context, AppRoutes.signup); + {{/if}} + }, + child: RichText( + text: TextSpan( + text: {{#if flags.supportsLocalization}}'auth.dont_have_account'.tr(){{else}}'Don\'t have an account? '{{/if}}, + style: tt.bodyMedium?.copyWith(color: cs.onSurfaceVariant), + children: [ + TextSpan( + text: {{#if flags.supportsLocalization}}'auth.sign_up'.tr(){{else}}'Sign Up'{{/if}}, + style: TextStyle( + color: cs.primary, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/cli/templates/partials/features/auth/session_provider.hbs b/cli/templates/partials/features/auth/session_provider.hbs new file mode 100644 index 0000000..bd11f91 --- /dev/null +++ b/cli/templates/partials/features/auth/session_provider.hbs @@ -0,0 +1,437 @@ +import 'dart:async'; +import 'package:{{flags.appSnake}}/src/imports/imports.dart'; +import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}domain/entities/user.dart{{else if (eq architecture "mvc")}}models/user_model.dart{{else if (eq architecture "mvvm")}}data/models/user_model.dart{{else}}features/auth/domain/entities/user.dart{{/if}}'; +import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}domain/repositories/{{else if (eq architecture "mvc")}}services/{{else if (eq architecture "mvvm")}}data/repositories/{{else}}features/auth/domain/repositories/{{/if}}auth_repository.dart'; + +{{#if flags.isRiverpod}} +import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}data/repositories/{{else if (eq architecture "mvc")}}services/{{else if (eq architecture "mvvm")}}data/repositories/{{else}}features/auth/data/repositories/{{/if}}auth_repository_impl.dart'; + +/// Provides the AuthRepository instance +final authRepositoryProvider = Provider((ref) { + return AuthRepositoryImpl(); +}); + +/// Provides a stream of auth state changes +final authStateStreamProvider = StreamProvider((ref) { + final repo = ref.watch(authRepositoryProvider); + return repo.onAuthStateChanged; +}); + +/// Provides the current session state +final sessionProvider = StateNotifierProvider((ref) { + final repo = ref.read(authRepositoryProvider); + return SessionNotifier(repository: repo); +}); + +/// Session states +enum SessionStatus { unknown, authenticated, unauthenticated } + +class SessionState { + final SessionStatus status; + final AppUser? user; + + const SessionState({this.status = SessionStatus.unknown, this.user}); + + SessionState copyWith({SessionStatus? status, AppUser? user}) { + return SessionState( + status: status ?? this.status, + user: user ?? this.user, + ); + } +} + +class SessionNotifier extends StateNotifier { + final AuthRepository _repository; + StreamSubscription? _authSub; + + SessionNotifier({required AuthRepository repository}) + : _repository = repository, + super(const SessionState()) { + _init(); + } + + Future _init() async { + // Check persisted session first + final result = await _repository.checkAuthState(); + result.fold( + (_) => state = const SessionState(status: SessionStatus.unauthenticated), + (user) { + if (user != null) { + state = SessionState(status: SessionStatus.authenticated, user: user); + } else { + state = const SessionState(status: SessionStatus.unauthenticated); + } + }, + ); + + // Listen for future changes + _authSub = _repository.onAuthStateChanged.listen((user) { + if (user != null) { + state = SessionState(status: SessionStatus.authenticated, user: user); + } else { + state = const SessionState(status: SessionStatus.unauthenticated); + } + }); + } + + Future logout() async { + await _repository.logout(); + state = const SessionState(status: SessionStatus.unauthenticated); + } + + @override + void dispose() { + _authSub?.cancel(); + super.dispose(); + } +} +{{else if flags.isBloc}} +/// Session events +abstract class SessionEvent extends Equatable { + const SessionEvent(); + @override + List get props => []; +} + +class SessionCheckRequested extends SessionEvent { + const SessionCheckRequested(); +} + +class SessionUserChanged extends SessionEvent { + final AppUser? user; + const SessionUserChanged(this.user); + @override + List get props => [user]; +} + +class SessionLogoutRequested extends SessionEvent { + const SessionLogoutRequested(); +} + +/// Session states +enum SessionStatus { unknown, authenticated, unauthenticated } + +class SessionState extends Equatable { + final SessionStatus status; + final AppUser? user; + + const SessionState({ + this.status = SessionStatus.unknown, + this.user, + }); + + const SessionState.unknown() : this(); + const SessionState.authenticated(AppUser user) : this(status: SessionStatus.authenticated, user: user); + const SessionState.unauthenticated() : this(status: SessionStatus.unauthenticated); + + @override + List get props => [status, user]; +} + +class SessionBloc extends Bloc { + final AuthRepository _repository; + StreamSubscription? _authSub; + + SessionBloc({required AuthRepository repository}) + : _repository = repository, + super(const SessionState.unknown()) { + on(_onCheckRequested); + on(_onUserChanged); + on(_onLogoutRequested); + + // Start checking + add(const SessionCheckRequested()); + } + + Future _onCheckRequested( + SessionCheckRequested event, + Emitter emit, + ) async { + final result = await _repository.checkAuthState(); + result.fold( + (_) => emit(const SessionState.unauthenticated()), + (user) { + if (user != null) { + emit(SessionState.authenticated(user)); + } else { + emit(const SessionState.unauthenticated()); + } + }, + ); + + // Listen for future changes + await _authSub?.cancel(); + _authSub = _repository.onAuthStateChanged.listen((user) { + add(SessionUserChanged(user)); + }); + } + + void _onUserChanged( + SessionUserChanged event, + Emitter emit, + ) { + if (event.user != null) { + emit(SessionState.authenticated(event.user!)); + } else { + emit(const SessionState.unauthenticated()); + } + } + + Future _onLogoutRequested( + SessionLogoutRequested event, + Emitter emit, + ) async { + await _repository.logout(); + emit(const SessionState.unauthenticated()); + } + + @override + Future close() { + _authSub?.cancel(); + return super.close(); + } +} +{{else if flags.isProvider}} +enum SessionStatus { unknown, authenticated, unauthenticated } + +class SessionProvider extends ChangeNotifier { + final AuthRepository _repository; + StreamSubscription? _authSub; + + SessionStatus _status = SessionStatus.unknown; + AppUser? _user; + + SessionStatus get status => _status; + AppUser? get user => _user; + bool get isAuthenticated => _status == SessionStatus.authenticated; + + SessionProvider({required AuthRepository repository}) : _repository = repository { + _init(); + } + + Future _init() async { + final result = await _repository.checkAuthState(); + result.fold( + (_) { + _status = SessionStatus.unauthenticated; + notifyListeners(); + }, + (user) { + if (user != null) { + _user = user; + _status = SessionStatus.authenticated; + } else { + _status = SessionStatus.unauthenticated; + } + notifyListeners(); + }, + ); + + _authSub = _repository.onAuthStateChanged.listen((user) { + if (user != null) { + _user = user; + _status = SessionStatus.authenticated; + } else { + _user = null; + _status = SessionStatus.unauthenticated; + } + notifyListeners(); + }); + } + + Future logout() async { + await _repository.logout(); + _user = null; + _status = SessionStatus.unauthenticated; + notifyListeners(); + } + + @override + void dispose() { + _authSub?.cancel(); + super.dispose(); + } +} +{{else if flags.isGetX}} +enum SessionStatus { unknown, authenticated, unauthenticated } + +class SessionController extends GetxController { + final AuthRepository _repository; + StreamSubscription? _authSub; + + final Rx status = SessionStatus.unknown.obs; + final Rx user = Rx(null); + + bool get isAuthenticated => status.value == SessionStatus.authenticated; + + SessionController({required AuthRepository repository}) : _repository = repository; + + @override + void onInit() { + super.onInit(); + _init(); + } + + Future _init() async { + final result = await _repository.checkAuthState(); + result.fold( + (_) => status.value = SessionStatus.unauthenticated, + (u) { + if (u != null) { + user.value = u; + status.value = SessionStatus.authenticated; + } else { + status.value = SessionStatus.unauthenticated; + } + }, + ); + + _authSub = _repository.onAuthStateChanged.listen((u) { + if (u != null) { + user.value = u; + status.value = SessionStatus.authenticated; + } else { + user.value = null; + status.value = SessionStatus.unauthenticated; + } + }); + } + + Future logout() async { + await _repository.logout(); + user.value = null; + status.value = SessionStatus.unauthenticated; + } + + @override + void onClose() { + _authSub?.cancel(); + super.onClose(); + } +} +{{else if flags.isMobX}} +part 'session_store.g.dart'; + +enum SessionStatus { unknown, authenticated, unauthenticated } + +class SessionStore = SessionStoreBase with _$SessionStore; + +abstract class SessionStoreBase with Store { + final AuthRepository _repository; + StreamSubscription? _authSub; + + SessionStoreBase({required AuthRepository repository}) : _repository = repository { + _init(); + } + + @observable + SessionStatus status = SessionStatus.unknown; + + @observable + AppUser? user; + + @computed + bool get isAuthenticated => status == SessionStatus.authenticated; + + @action + Future _init() async { + final result = await _repository.checkAuthState(); + result.fold( + (_) => status = SessionStatus.unauthenticated, + (u) { + if (u != null) { + user = u; + status = SessionStatus.authenticated; + } else { + status = SessionStatus.unauthenticated; + } + }, + ); + + _authSub = _repository.onAuthStateChanged.listen((u) { + runInAction(() { + if (u != null) { + user = u; + status = SessionStatus.authenticated; + } else { + user = null; + status = SessionStatus.unauthenticated; + } + }); + }); + } + + @action + Future logout() async { + await _repository.logout(); + user = null; + status = SessionStatus.unauthenticated; + } + + void dispose() { + _authSub?.cancel(); + } +} +{{else}} +enum SessionStatus { unknown, authenticated, unauthenticated } + +class SessionManager extends ChangeNotifier { + final AuthRepository _repository; + StreamSubscription? _authSub; + + SessionStatus _status = SessionStatus.unknown; + AppUser? _user; + + SessionStatus get status => _status; + AppUser? get user => _user; + bool get isAuthenticated => _status == SessionStatus.authenticated; + + SessionManager({required AuthRepository repository}) : _repository = repository { + _init(); + } + + Future _init() async { + final result = await _repository.checkAuthState(); + result.fold( + (_) { + _status = SessionStatus.unauthenticated; + notifyListeners(); + }, + (user) { + if (user != null) { + _user = user; + _status = SessionStatus.authenticated; + } else { + _status = SessionStatus.unauthenticated; + } + notifyListeners(); + }, + ); + + _authSub = _repository.onAuthStateChanged.listen((user) { + if (user != null) { + _user = user; + _status = SessionStatus.authenticated; + } else { + _user = null; + _status = SessionStatus.unauthenticated; + } + notifyListeners(); + }); + } + + Future logout() async { + await _repository.logout(); + _user = null; + _status = SessionStatus.unauthenticated; + notifyListeners(); + } + + @override + void dispose() { + _authSub?.cancel(); + super.dispose(); + } +} +{{/if}} + diff --git a/cli/templates/partials/features/auth/signup_screen.hbs b/cli/templates/partials/features/auth/signup_screen.hbs new file mode 100644 index 0000000..df4970e --- /dev/null +++ b/cli/templates/partials/features/auth/signup_screen.hbs @@ -0,0 +1,511 @@ +import 'package:{{flags.appSnake}}/src/imports/core_imports.dart'; +import 'package:{{flags.appSnake}}/src/imports/packages_imports.dart'; + +{{#if flags.isRiverpod}} +import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}presentation/providers/{{else if (eq architecture "mvc")}}controllers/auth/{{else if (eq architecture "mvvm")}}ui/auth/providers/{{else}}features/auth/presentation/providers/{{/if}}auth_provider.dart'; +{{else if flags.isBloc}} +import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}presentation/providers/{{else if (eq architecture "mvc")}}controllers/auth/{{else if (eq architecture "mvvm")}}ui/auth/bloc/{{else}}features/auth/presentation/providers/{{/if}}auth_bloc.dart'; +{{else if flags.isProvider}} +import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}presentation/providers/{{else if (eq architecture "mvc")}}controllers/auth/{{else if (eq architecture "mvvm")}}ui/auth/providers/{{else}}features/auth/presentation/providers/{{/if}}auth_provider.dart'; +{{else if flags.isGetX}} +import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}presentation/controllers/auth/{{else if (eq architecture "mvc")}}controllers/auth/{{else if (eq architecture "mvvm")}}ui/auth/controllers/{{else}}features/auth/presentation/controllers/{{/if}}auth_controller.dart'; +{{else if flags.isMobX}} +{{#if flags.usesFlutterHooks}} +import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}presentation/providers/{{else if (eq architecture "mvc")}}controllers/auth/{{else if (eq architecture "mvvm")}}ui/auth/stores/{{else}}features/auth/presentation/providers/{{/if}}auth_store.dart'; +{{/if}} +{{else}} +{{#if flags.usesFlutterHooks}} +import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}presentation/providers/{{else if (eq architecture "mvc")}}controllers/auth/{{else if (eq architecture "mvvm")}}ui/auth/view_models/{{else}}features/auth/presentation/providers/{{/if}}auth_view_model.dart'; +{{/if}} +{{/if}} + +{{#if (eq flags.routerPackage "auto_route")}} +@RoutePage() +{{/if}} +{{#if flags.usesFlutterHooks}} +class SignupScreen extends {{#if flags.isRiverpod}}HookConsumerWidget{{else}}HookWidget{{/if}} { + const SignupScreen({super.key}); + + @override + Widget build(BuildContext context, {{#if flags.isRiverpod}}WidgetRef ref{{/if}}) { + final formKey = useMemoized(() => GlobalKey()); + final nameController = useTextEditingController(); + final emailController = useTextEditingController(); + final passwordController = useTextEditingController(); + final confirmPasswordController = useTextEditingController(); + final obscurePassword = useState(true); + final obscureConfirmPassword = useState(true); + + {{#if flags.isRiverpod}} + final isLoading = ref.watch(authControllerProvider); + {{else if flags.isBloc}} + final isLoading = context.select((AuthBloc bloc) => bloc.state.isLoading); + {{else if flags.isProvider}} + final isLoading = context.select((AuthProvider p) => p.isLoading); + {{else if flags.isGetX}} + final controller = Get.find(); + final isLoading = controller.isLoading.value; + {{else if flags.isMobX}} + final authStore = useMemoized(() => AuthStore(repository: AuthRepositoryImpl()), []); + final isLoading = authStore.isLoading; + {{else if flags.isNoneState}} + final viewModel = useMemoized(() => AuthViewModel(repository: AuthRepositoryImpl()), []); + useListenable(viewModel); + final isLoading = viewModel.isLoading; + {{else}} + final isLoadingState = useState(false); + final isLoading = isLoadingState.value; + {{/if}} + + final cs = context.theme.colorScheme; + final tt = context.theme.textTheme; + + Future handleSignup() async { + if (!(formKey.currentState?.validate() ?? false)) return; + + {{#if flags.isRiverpod}} + ref.read(authControllerProvider.notifier).signUp( + context: context, + name: nameController.text, + email: emailController.text, + password: passwordController.text, + ); + {{else if flags.isBloc}} + context.read().add( + SignUpRequested( + context: context, + name: nameController.text, + email: emailController.text, + password: passwordController.text, + ), + ); + {{else if flags.isProvider}} + context.read().signUp( + context: context, + name: nameController.text, + email: emailController.text, + password: passwordController.text, + ); + {{else if flags.isGetX}} + controller.signUp( + context: context, + name: nameController.text, + email: emailController.text, + password: passwordController.text, + ); + {{else if flags.isMobX}} + authStore.signUp( + context: context, + name: nameController.text.trim(), + email: emailController.text.trim(), + password: passwordController.text.trim(), + ); + {{else if flags.isNoneState}} + viewModel.signUp( + context: context, + name: nameController.text.trim(), + email: emailController.text.trim(), + password: passwordController.text.trim(), + ); + {{else}} + isLoadingState.value = true; + try { + final result = await AuthService.instance.signUp( + name: nameController.text.trim(), + email: emailController.text.trim(), + password: passwordController.text.trim(), + ); + + result.fold( + (failure) { + if (context.mounted) { + showToast(context, message: failure.message, status: 'error'); + } + }, + (user) { + if (context.mounted) { + {{#if (eq flags.routerPackage "go_router")}} + context.go(AppRoutes.home); + {{else if (eq flags.routerPackage "auto_route")}} + context.replaceRoute(const HomeRoute()); + {{else}} + Navigator.pushReplacementNamed(context, AppRoutes.home); + {{/if}} + } + }, + ); + } catch (e) { + if (context.mounted) { + showToast(context, message: e.toString(), status: 'error'); + } + } finally { + isLoadingState.value = false; + } + {{/if}} + } + + {{#if flags.isGetX}} + return Obx(() => _SignupView( + {{else}} + return _SignupView( + {{/if}} + formKey: formKey, + nameController: nameController, + emailController: emailController, + passwordController: passwordController, + confirmPasswordController: confirmPasswordController, + obscurePassword: obscurePassword.value, + obscureConfirmPassword: obscureConfirmPassword.value, + isLoading: isLoading, + onToggleObscure: () => obscurePassword.value = !obscurePassword.value, + onToggleConfirmObscure: () => obscureConfirmPassword.value = !obscureConfirmPassword.value, + onSignup: handleSignup, + cs: cs, + tt: tt, + ); + {{#if flags.isGetX}} + ); + {{/if}} + } +} +{{else}} +class SignupScreen extends {{#if flags.isRiverpod}}ConsumerStatefulWidget{{else}}StatefulWidget{{/if}} { + const SignupScreen({super.key}); + + @override + {{#if flags.isRiverpod}} + ConsumerState createState() => _SignupScreenState(); + {{else}} + State createState() => _SignupScreenState(); + {{/if}} +} + +class _SignupScreenState extends {{#if flags.isRiverpod}}ConsumerState{{else}}State{{/if}} { + final _formKey = GlobalKey(); + final _nameController = TextEditingController(); + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + final _confirmPasswordController = TextEditingController(); + bool _obscurePassword = true; + bool _obscureConfirmPassword = true; + {{#if (or flags.isNoneState flags.isMobX)}} + bool _isLoading = false; + {{/if}} + + @override + void dispose() { + _nameController.dispose(); + _emailController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + {{#if flags.isRiverpod}} + final isLoading = ref.watch(authControllerProvider); + {{else if flags.isBloc}} + final isLoading = context.select((AuthBloc bloc) => bloc.state.isLoading); + {{else if flags.isProvider}} + final isLoading = context.select((AuthProvider p) => p.isLoading); + {{else if flags.isGetX}} + final controller = Get.find(); + {{else if flags.isMobX}} + final isLoading = _isLoading; + {{else}} + final isLoading = _isLoading; + {{/if}} + + final cs = context.theme.colorScheme; + final tt = context.theme.textTheme; + + Future handleSignup() async { + if (!(_formKey.currentState?.validate() ?? false)) return; + + {{#if (or flags.isNoneState flags.isMobX)}} + setState(() => _isLoading = true); + {{/if}} + + {{#if flags.isRiverpod}} + ref.read(authControllerProvider.notifier).signUp( + context: context, + name: _nameController.text, + email: _emailController.text, + password: _passwordController.text, + ); + {{else if flags.isBloc}} + context.read().add( + SignUpRequested( + context: context, + name: _nameController.text, + email: _emailController.text, + password: _passwordController.text, + ), + ); + {{else if flags.isProvider}} + context.read().signUp( + context: context, + name: _nameController.text, + email: _emailController.text, + password: _passwordController.text, + ); + {{else if flags.isGetX}} + controller.signUp( + context: context, + name: _nameController.text, + email: _emailController.text, + password: _passwordController.text, + ); + {{else}} + try { + final result = await AuthService.instance.signUp( + name: _nameController.text.trim(), + email: _emailController.text.trim(), + password: _passwordController.text.trim(), + ); + + result.fold( + (failure) { + if (context.mounted) { + showToast(context, message: failure.message, status: 'error'); + } + }, + (user) { + if (context.mounted) { + {{#if (eq flags.routerPackage "go_router")}} + context.go(AppRoutes.home); + {{else if (eq flags.routerPackage "auto_route")}} + context.replaceRoute(const HomeRoute()); + {{else}} + Navigator.pushReplacementNamed(context, AppRoutes.home); + {{/if}} + } + }, + ); + } catch (e) { + if (context.mounted) { + showToast(context, message: e.toString(), status: 'error'); + } + } finally { + if (context.mounted) setState(() => _isLoading = false); + } + {{/if}} + } + + {{#if flags.isGetX}} + return Obx(() { + final isLoading = controller.isLoading.value; + return _SignupView( + formKey: _formKey, + nameController: _nameController, + emailController: _emailController, + passwordController: _passwordController, + confirmPasswordController: _confirmPasswordController, + obscurePassword: _obscurePassword, + obscureConfirmPassword: _obscureConfirmPassword, + isLoading: isLoading, + onToggleObscure: () => setState(() => _obscurePassword = !_obscurePassword), + onToggleConfirmObscure: () => setState(() => _obscureConfirmPassword = !_obscureConfirmPassword), + onSignup: handleSignup, + cs: cs, + tt: tt, + ); + }); + {{else}} + return _SignupView( + formKey: _formKey, + nameController: _nameController, + emailController: _emailController, + passwordController: _passwordController, + confirmPasswordController: _confirmPasswordController, + obscurePassword: _obscurePassword, + obscureConfirmPassword: _obscureConfirmPassword, + isLoading: isLoading, + onToggleObscure: () => setState(() => _obscurePassword = !_obscurePassword), + onToggleConfirmObscure: () => setState(() => _obscureConfirmPassword = !_obscureConfirmPassword), + onSignup: handleSignup, + cs: cs, + tt: tt, + ); + {{/if}} + } +} +{{/if}} + +class _SignupView extends StatelessWidget { + const _SignupView({ + required this.formKey, + required this.nameController, + required this.emailController, + required this.passwordController, + required this.confirmPasswordController, + required this.obscurePassword, + required this.obscureConfirmPassword, + required this.isLoading, + required this.onToggleObscure, + required this.onToggleConfirmObscure, + required this.onSignup, + required this.cs, + required this.tt, + }); + + final GlobalKey formKey; + final TextEditingController nameController; + final TextEditingController emailController; + final TextEditingController passwordController; + final TextEditingController confirmPasswordController; + final bool obscurePassword; + final bool obscureConfirmPassword; + final bool isLoading; + final VoidCallback onToggleObscure; + final VoidCallback onToggleConfirmObscure; + final VoidCallback onSignup; + final ColorScheme cs; + final TextTheme tt; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: EdgeInsets.symmetric(horizontal: {{res 'AppSpacing.lg' 'w' flags.usesScreenutil}}), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox(height: {{res 'AppSpacing.xl' 'h' flags.usesScreenutil}}), + Text( + {{#if flags.supportsLocalization}}'auth.sign_up'.tr(){{else}}'Create Account'{{/if}}, + style: tt.headlineMedium?.copyWith(fontWeight: FontWeight.bold), + ), + SizedBox(height: {{res 'AppSpacing.sm' 'h' flags.usesScreenutil}}), + Text( + {{#if flags.supportsLocalization}}'auth.sign_up_subtitle'.tr(){{else}}'Join us and start your journey'{{/if}}, + textAlign: TextAlign.center, + style: tt.bodyMedium?.copyWith(color: cs.onSurfaceVariant), + ), + SizedBox(height: {{res 'AppSpacing.xxxl' 'h' flags.usesScreenutil}}), + Form( + key: formKey, + child: Column( + children: [ + AppTextField( + controller: nameController, + enabled: !isLoading, + label: {{#if flags.supportsLocalization}}'auth.name'.tr(){{else}}'Full Name'{{/if}}, + prefixIcon: const Icon({{#if flags.usesIconsaxPlus}}IconsaxPlusBold.user{{else}}Icons.person_outline{{/if}}), + validator: (v) { + if (AppUtils.isBlank(v)) { + return {{#if flags.supportsLocalization}}'auth.name_required'.tr(){{else}}'Name is required'{{/if}}; + } + return null; + }, + ), + SizedBox(height: {{res 'AppSpacing.md' 'h' flags.usesScreenutil}}), + AppTextField( + controller: emailController, + enabled: !isLoading, + label: {{#if flags.supportsLocalization}}'auth.email'.tr(){{else}}'Email'{{/if}}, + prefixIcon: const Icon({{#if flags.usesIconsaxPlus}}IconsaxPlusBold.sms{{else}}Icons.email_outlined{{/if}}), + validator: (v) { + if (AppUtils.isBlank(v)) { + return {{#if flags.supportsLocalization}}'auth.email_required'.tr(){{else}}'Email is required'{{/if}}; + } + if (!AppUtils.isValidEmail(v!)) { + return {{#if flags.supportsLocalization}}'auth.email_invalid'.tr(){{else}}'Enter a valid email'{{/if}}; + } + return null; + }, + ), + SizedBox(height: {{res 'AppSpacing.md' 'h' flags.usesScreenutil}}), + AppTextField( + controller: passwordController, + enabled: !isLoading, + label: {{#if flags.supportsLocalization}}'auth.password'.tr(){{else}}'Password'{{/if}}, + obscureText: obscurePassword, + prefixIcon: const Icon({{#if flags.usesIconsaxPlus}}IconsaxPlusBold.lock{{else}}Icons.lock_outline{{/if}}), + suffixIcon: IconButton( + icon: Icon(obscurePassword ? Icons.visibility_off : Icons.visibility), + onPressed: onToggleObscure, + ), + validator: (v) { + if (AppUtils.isBlank(v)) { + return {{#if flags.supportsLocalization}}'auth.password_required'.tr(){{else}}'Password is required'{{/if}}; + } + if (v!.length < 6) { + return {{#if flags.supportsLocalization}}'auth.password_too_short'.tr(){{else}}'Password must be at least 6 characters'{{/if}}; + } + return null; + }, + ), + SizedBox(height: {{res 'AppSpacing.md' 'h' flags.usesScreenutil}}), + AppTextField( + controller: confirmPasswordController, + enabled: !isLoading, + label: {{#if flags.supportsLocalization}}'auth.confirm_password'.tr(){{else}}'Confirm Password'{{/if}}, + obscureText: obscureConfirmPassword, + prefixIcon: const Icon({{#if flags.usesIconsaxPlus}}IconsaxPlusBold.lock{{else}}Icons.lock_outline{{/if}}), + suffixIcon: IconButton( + icon: Icon(obscureConfirmPassword ? Icons.visibility_off : Icons.visibility), + onPressed: onToggleConfirmObscure, + ), + validator: (v) { + if (AppUtils.isBlank(v)) { + return {{#if flags.supportsLocalization}}'auth.confirm_password_required'.tr(){{else}}'Confirm password is required'{{/if}}; + } + if (v != passwordController.text) { + return {{#if flags.supportsLocalization}}'auth.passwords_do_not_match'.tr(){{else}}'Passwords do not match'{{/if}}; + } + return null; + }, + ), + SizedBox(height: {{res 'AppSpacing.lg' 'h' flags.usesScreenutil}}), + AppButton( + label: 'Sign Up', + isLoading: isLoading, + onPressed: isLoading ? null : onSignup, + width: ButtonSize.large, + isFullWidth: false, + ), + ], + ), + ), + SizedBox(height: {{res 'AppSpacing.xxxl' 'h' flags.usesScreenutil}}), + InkWell( + onTap: () { + {{#if (eq flags.routerPackage "go_router")}} + context.push(AppRoutes.login); + {{else if (eq flags.routerPackage "auto_route")}} + context.pushRoute(const LoginRoute()); + {{else if flags.isGetX}} + Get.toNamed(AppRoutes.login); + {{else}} + Navigator.pushNamed(context, AppRoutes.login); + {{/if}} + }, + child: RichText( + text: TextSpan( + text: {{#if flags.supportsLocalization}}'auth.already_have_account'.tr(){{else}}'Already have an account? '{{/if}}, + style: tt.bodyMedium?.copyWith(color: cs.onSurfaceVariant), + children: [ + TextSpan( + text: {{#if flags.supportsLocalization}}'auth.log_in'.tr(){{else}}'Log In'{{/if}}, + style: TextStyle( + color: cs.primary, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ), + SizedBox(height: {{res 'AppSpacing.xl' 'h' flags.usesScreenutil}}), + ], + ), + ), + ), + ), + ); + } +} diff --git a/cli/templates/partials/features/auth/user_model.hbs b/cli/templates/partials/features/auth/user_model.hbs new file mode 100644 index 0000000..a2fbc9e --- /dev/null +++ b/cli/templates/partials/features/auth/user_model.hbs @@ -0,0 +1,23 @@ +import 'package:equatable/equatable.dart'; + +class AppUser extends Equatable { + final String id; + final String email; + final String? name; + final String? photoUrl; + + const AppUser({ + required this.id, + required this.email, + this.name, + this.photoUrl, + }); + + factory AppUser.empty() => const AppUser(id: '', email: ''); + + bool get isEmpty => id.isEmpty; + bool get isNotEmpty => id.isNotEmpty; + + @override + List get props => [id, email, name, photoUrl]; +} diff --git a/cli/templates/partials/features/home/home_page.hbs b/cli/templates/partials/features/home/home_page.hbs new file mode 100644 index 0000000..c760e21 --- /dev/null +++ b/cli/templates/partials/features/home/home_page.hbs @@ -0,0 +1,156 @@ +import 'package:{{flags.appSnake}}/src/imports/core_imports.dart'; +import 'package:{{flags.appSnake}}/src/imports/packages_imports.dart'; + +{{#if flags.isRiverpod}} +import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}presentation/providers/session_provider.dart{{else if (eq architecture "mvc")}}controllers/auth/session_provider.dart{{else if (eq architecture "mvvm")}}ui/auth/providers/session_provider.dart{{else}}features/auth/presentation/providers/session_provider.dart{{/if}}'; +{{else if flags.isBloc}} +import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}presentation/providers/session_bloc.dart{{else if (eq architecture "mvc")}}controllers/auth/session_bloc.dart{{else if (eq architecture "mvvm")}}ui/auth/bloc/session_bloc.dart{{else}}features/auth/presentation/providers/session_bloc.dart{{/if}}'; +{{else if flags.isGetX}} +import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}presentation/providers/session_controller.dart{{else if (eq architecture "mvc")}}controllers/auth/session_controller.dart{{else if (eq architecture "mvvm")}}ui/auth/controllers/session_controller.dart{{else}}features/auth/presentation/providers/session_controller.dart{{/if}}'; +{{else if flags.isMobX}} +import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}presentation/providers/session_store.dart{{else if (eq architecture "mvc")}}controllers/auth/session_store.dart{{else if (eq architecture "mvvm")}}ui/auth/stores/session_store.dart{{else}}features/auth/presentation/providers/session_store.dart{{/if}}'; +{{else if flags.isProvider}} +import 'package:{{flags.appSnake}}/src/{{#if (eq architecture "layer-first")}}presentation/providers/session_provider.dart{{else if (eq architecture "mvc")}}controllers/auth/session_provider.dart{{else if (eq architecture "mvvm")}}ui/auth/providers/session_provider.dart{{else}}features/auth/presentation/providers/session_provider.dart{{/if}}'; +{{/if}} + +{{#if (eq flags.routerPackage "auto_route")}} +@RoutePage() +{{/if}} + +{{#if flags.usesFlutterHooks}} +{{#if flags.isRiverpod}} +class HomePage extends HookConsumerWidget { +{{else}} +class HomePage extends HookWidget { +{{/if}} +{{else}} +{{#if flags.isRiverpod}} +class HomePage extends ConsumerWidget { +{{else}} +class HomePage extends StatelessWidget { +{{/if}} +{{/if}} + const HomePage({super.key}); + + @override +{{#if flags.isRiverpod}} + Widget build(BuildContext context, WidgetRef ref) { +{{else}} + Widget build(BuildContext context) { +{{/if}} + final theme = context.theme; + final colorScheme = theme.colorScheme; + final textTheme = theme.textTheme; + + {{#if flags.isRiverpod}} + final session = ref.watch(sessionProvider); + {{else if flags.isBloc}} + final session = context.watch().state; + {{else if flags.isGetX}} + final session = Get.find(); + {{else if flags.isProvider}} + final session = context.watch(); + {{else if flags.isMobX}} + final session = context.watch(); + {{/if}} + {{#unless (or flags.isGetX flags.isMobX)}} + {{#if (or flags.isRiverpod flags.isBloc flags.isProvider)}}final{{else}}const{{/if}} user = {{#if flags.isRiverpod}}session.user{{else if flags.isBloc}}session.user{{else if flags.isProvider}}session.user{{else}}null{{/if}}; + {{/unless}} + + return Scaffold( + backgroundColor: colorScheme.surface, + appBar: AppTopBar( + title: {{#if flags.supportsLocalization}}'home.home_title'.tr(){{else}}'Home'{{/if}}, + ), + body: SafeArea( + child: Padding( + padding: EdgeInsets.all({{res 'AppSpacing.xl' 'w' flags.usesScreenutil}}), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AppIcon( + icon: {{#if flags.usesIconsaxPlus}}IconsaxPlusLinear.home{{else if flags.usesFlutterRemix}}FlutterRemix.home_line{{else if flags.usesHugeicons}}HugeIcons.strokeRoundedHome01{{else}}Icons.home_rounded{{/if}}, + size: {{res 60 'sp' flags.usesScreenutil}}, + color: colorScheme.primary, + ), + SizedBox(height: {{res 'AppSpacing.lg' 'h' flags.usesScreenutil}}), + {{#if flags.isGetX}} + Obx(() { + final user = session.user.value; + return Text( + user?.name ?? user?.email ?? ({{#if flags.supportsLocalization}}'home.welcome_home'.tr(){{else}}'Welcome Home!'{{/if}}), + textAlign: TextAlign.center, + style: textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.w900, + color: colorScheme.onSurface, + fontSize: {{res 28 'sp' flags.usesScreenutil}}, + ), + ); + }), + {{else if flags.isMobX}} + Observer(builder: (_) { + final user = session.user; + return Text( + user?.name ?? user?.email ?? ({{#if flags.supportsLocalization}}'home.welcome_home'.tr(){{else}}'Welcome Home!'{{/if}}), + textAlign: TextAlign.center, + style: textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.w900, + color: colorScheme.onSurface, + fontSize: {{res 28 'sp' flags.usesScreenutil}}, + ), + ); + }), + {{else}} + Text( + user?.name ?? user?.email ?? ({{#if flags.supportsLocalization}}'home.welcome_home'.tr(){{else}}'Welcome Home!'{{/if}}), + textAlign: TextAlign.center, + style: textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.w900, + color: colorScheme.onSurface, + fontSize: {{res 28 'sp' flags.usesScreenutil}}, + ), + ), + {{/if}} + SizedBox(height: {{res 'AppSpacing.md' 'h' flags.usesScreenutil}}), + {{#if flags.isGetX}} + Obx(() { + final user = session.user.value; + return Text( + user != null && user.name != null ? user.email : ({{#if flags.supportsLocalization}}'home.home_subtitle'.tr(){{else}}'You have successfully completed the onboarding process.'{{/if}}), + textAlign: TextAlign.center, + style: textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + fontSize: {{res 14 'sp' flags.usesScreenutil}}, + ), + ); + }), + {{else if flags.isMobX}} + Observer(builder: (_) { + final user = session.user; + return Text( + user != null && user.name != null ? user.email : ({{#if flags.supportsLocalization}}'home.home_subtitle'.tr(){{else}}'You have successfully completed the onboarding process.'{{/if}}), + textAlign: TextAlign.center, + style: textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + fontSize: {{res 14 'sp' flags.usesScreenutil}}, + ), + ); + }), + {{else}} + Text( + user != null && user.name != null ? user.email : ({{#if flags.supportsLocalization}}'home.home_subtitle'.tr(){{else}}'You have successfully completed the onboarding process.'{{/if}}), + textAlign: TextAlign.center, + style: textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + fontSize: {{res 14 'sp' flags.usesScreenutil}}, + ), + ), + {{/if}} + ], + ), + ), + ), + ); + } +} diff --git a/cli/templates/partials/features/onboarding/onboarding_page.hbs b/cli/templates/partials/features/onboarding/onboarding_page.hbs new file mode 100644 index 0000000..708354c --- /dev/null +++ b/cli/templates/partials/features/onboarding/onboarding_page.hbs @@ -0,0 +1,264 @@ +import 'package:{{flags.appSnake}}/src/imports/imports.dart'; + +{{#if (eq flags.routerPackage "auto_route")}} +@RoutePage() +{{/if}} +{{#if flags.usesFlutterHooks}} +class OnboardingPage extends HookWidget { + const OnboardingPage({super.key}); + + @override + Widget build(BuildContext context) { + final theme = context.theme; + final colorScheme = theme.colorScheme; + final textTheme = theme.textTheme; + + final pageController = usePageController(); + final currentIndex = useState(0); + + final List> onboardingData = useMemoized(() => [ + { + 'title': {{#if flags.supportsLocalization}}'onboarding.onboarding_title_1'.tr(){{else}}'Your Journey,\nPerfectly Planned'{{/if}}, + 'subtitle': + {{#if flags.supportsLocalization}}'onboarding.onboarding_subtitle_1'.tr(){{else}}'Effortlessly create and organize your\ndream trips. Start exploring now!'{{/if}}, + 'pageWidget': const FlutterLogo(size: 200), + }, + { + 'title': {{#if flags.supportsLocalization}}'onboarding.onboarding_title_2'.tr(){{else}}'Discover\nFriends Nearby'{{/if}}, + 'subtitle': + {{#if flags.supportsLocalization}}'onboarding.onboarding_subtitle_2'.tr(){{else}}'See where your friends are traveling and\nexplore the world together.'{{/if}}, + 'pageWidget': const FlutterLogo(size: 200), + }, + { + 'title': {{#if flags.supportsLocalization}}'onboarding.onboarding_title_3'.tr(){{else}}'Stay Updated\nwith Top Places'{{/if}}, + 'subtitle': + {{#if flags.supportsLocalization}}'onboarding.onboarding_subtitle_3'.tr(){{else}}'Find trending destinations and must-see attractions,\nall tailored to enhance your travel plans.'{{/if}}, + 'pageWidget': const FlutterLogo(size: 200), + }, + ]); + + void onGetStarted() { + // Navigate back or to home. For template purpose: + {{#if (eq flags.routerPackage "go_router")}} + context.go(AppRoutes.login); + {{else if (eq flags.routerPackage "auto_route")}} + context.replaceRoute(const LoginRoute()); + {{else}} + Navigator.pushReplacementNamed(context, AppRoutes.login); + {{/if}} + } + + return _OnboardingView( + theme: theme, + colorScheme: colorScheme, + textTheme: textTheme, + pageController: pageController, + currentIndex: currentIndex.value, + onboardingData: onboardingData, + onPageChanged: (index) => currentIndex.value = index, + onGetStarted: onGetStarted, + ); + } +} +{{else}} +class OnboardingPage extends StatefulWidget { + const OnboardingPage({super.key}); + + @override + State createState() => _OnboardingPageState(); +} + +class _OnboardingPageState extends State { + late final PageController _pageController; + int _currentIndex = 0; + + late final List> _onboardingData; + + @override + void initState() { + super.initState(); + _pageController = PageController(); + _onboardingData = [ + { + 'title': {{#if flags.supportsLocalization}}'onboarding.onboarding_title_1'.tr(){{else}}'Your Journey,\nPerfectly Planned'{{/if}}, + 'subtitle': + {{#if flags.supportsLocalization}}'onboarding.onboarding_subtitle_1'.tr(){{else}}'Effortlessly create and organize your\ndream trips. Start exploring now!'{{/if}}, + 'pageWidget': const FlutterLogo(size: 200), + }, + { + 'title': {{#if flags.supportsLocalization}}'onboarding.onboarding_title_2'.tr(){{else}}'Discover\nFriends Nearby'{{/if}}, + 'subtitle': + {{#if flags.supportsLocalization}}'onboarding.onboarding_subtitle_2'.tr(){{else}}'See where your friends are traveling and\nexplore the world together.'{{/if}}, + 'pageWidget': const FlutterLogo(size: 200), + }, + { + 'title': {{#if flags.supportsLocalization}}'onboarding.onboarding_title_3'.tr(){{else}}'Stay Updated\nwith Top Places'{{/if}}, + 'subtitle': + {{#if flags.supportsLocalization}}'onboarding.onboarding_subtitle_3'.tr(){{else}}'Find trending destinations and must-see attractions,\nall tailored to enhance your travel plans.'{{/if}}, + 'pageWidget': const FlutterLogo(size: 200), + }, + ]; + } + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + void _onGetStarted() { + // Navigate back or to home. For template purpose: + {{#if (eq flags.routerPackage "go_router")}} + context.go(AppRoutes.login); + {{else if (eq flags.routerPackage "auto_route")}} + context.replaceRoute(const LoginRoute()); + {{else}} + Navigator.pushReplacementNamed(context, AppRoutes.login); + {{/if}} + } + + @override + Widget build(BuildContext context) { + final theme = context.theme; + final colorScheme = theme.colorScheme; + final textTheme = theme.textTheme; + + return _OnboardingView( + theme: theme, + colorScheme: colorScheme, + textTheme: textTheme, + pageController: _pageController, + currentIndex: _currentIndex, + onboardingData: _onboardingData, + onPageChanged: (index) => setState(() => _currentIndex = index), + onGetStarted: _onGetStarted, + ); + } +} +{{/if}} + +class _OnboardingView extends StatelessWidget { + const _OnboardingView({ + required this.theme, + required this.colorScheme, + required this.textTheme, + required this.pageController, + required this.currentIndex, + required this.onboardingData, + required this.onPageChanged, + required this.onGetStarted, + }); + + final ThemeData theme; + final ColorScheme colorScheme; + final TextTheme textTheme; + final PageController pageController; + final int currentIndex; + final List> onboardingData; + final ValueChanged onPageChanged; + final VoidCallback onGetStarted; + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: colorScheme.surface, + body: SafeArea( + child: Column( + children: [ + // Top branding + Padding( + padding: EdgeInsets.only( + top: {{res 'AppSpacing.lg' 'h' flags.usesScreenutil}}, + bottom: {{res 'AppSpacing.md' 'h' flags.usesScreenutil}}, + ), + child: Text( + 'FlutterInit.', + style: textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w900, + color: colorScheme.onSurface, + fontSize: {{res 22 'sp' flags.usesScreenutil}}, + ), + ), + ), + + // PageView + Expanded( + child: PageView.builder( + controller: pageController, + itemCount: onboardingData.length, + onPageChanged: onPageChanged, + itemBuilder: (context, index) { + return Column( + children: [ + // Dynamic Illustration Section + Expanded( + child: Center( + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: {{res 'AppSpacing.lg' 'w' flags.usesScreenutil}}, + ), + child: onboardingData[index]['pageWidget'] as Widget, + ), + ), + ), + + // Text Section + Padding( + padding: EdgeInsets.symmetric( + horizontal: {{res 'AppSpacing.xl' 'w' flags.usesScreenutil}}, + ), + child: Column( + children: [ + Text( + onboardingData[index]['title'] as String, + textAlign: TextAlign.center, + style: textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.w700, + color: colorScheme.onSurface, + height: 1.2, + fontSize: {{res 24 'sp' flags.usesScreenutil}}, + ), + ), + SizedBox(height: {{res 'AppSpacing.md' 'h' flags.usesScreenutil}}), + Text( + onboardingData[index]['subtitle'] as String, + textAlign: TextAlign.center, + style: textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + height: 1.5, + fontSize: {{res 14 'sp' flags.usesScreenutil}}, + ), + ), + ], + ), + ), + {{#if flags.usesScreenutil}}SizedBox(height: 40.h){{else}}const SizedBox(height: 40){{/if}}, + ], + ); + }, + ), + ), + + // Bottom Section: Dots and Button + Padding( + padding: EdgeInsets.all({{res 'AppSpacing.xl' 'w' flags.usesScreenutil}}), + child: Column( + children: [ + {{#if flags.usesScreenutil}}SizedBox(height: AppSpacing.xl){{else}}const SizedBox(height: 32){{/if}}, + // Get Started Button + AppButton( + label: {{#if flags.supportsLocalization}}'shared.get_started'.tr(){{else}}'Get Started'{{/if}}, + onPressed: onGetStarted, + variant: ButtonVariant.primary, + width: ButtonSize.medium, + ), + {{#if flags.usesScreenutil}}SizedBox(height: AppSpacing.md){{else}}const SizedBox(height: 16){{/if}}, + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/cli/templates/partials/llm/add-feature-workflow.hbs b/cli/templates/partials/llm/add-feature-workflow.hbs new file mode 100644 index 0000000..6571e39 --- /dev/null +++ b/cli/templates/partials/llm/add-feature-workflow.hbs @@ -0,0 +1,38 @@ +## How to add a new feature + +{{#if (eq architecture "clean")}} +1. Create domain entity and repository contract under `lib/src/features//domain/`. +2. Add use case(s) in `domain/usecases/` (single responsibility). +3. Add data model, datasource, and repository implementation under `data/`. +4. Wire state ({{stateManagement}}) under `presentation/`. +5. Build screens and widgets under `presentation/`. +6. Register the route in `lib/src/routing/app_router.dart`. +7. Export new public API only through existing barrel files if needed. +{{/if}} + +{{#if (eq architecture "feature-first")}} +1. Create `lib/src/features//` with model, service, state, and view folders as needed. +2. Keep all feature code inside that folder — use `shared/` only for truly global UI/utils. +3. Register the route in `lib/src/routing/app_router.dart`. +{{/if}} + +{{#if (eq architecture "mvc")}} +1. Add models under `lib/src/models/` (or feature subfolder if you introduce one). +2. Add or extend services in `lib/src/services/`. +3. Add a controller under `lib/src/controllers//`. +4. Add views under `lib/src/views//`. +5. Register the route in `lib/src/routing/app_router.dart`. +{{/if}} + +{{#if (eq architecture "mvvm")}} +1. Add data models and repository under `lib/src/data/`. +2. Add UI, view model / provider / bloc under `lib/src/ui//`. +3. Register the route in `lib/src/routing/app_router.dart`. +{{/if}} + +{{#if (eq architecture "layer-first")}} +1. Add domain entity and repository contract under `lib/src/domain/`. +2. Implement datasource/model/repository under `lib/src/data/`. +3. Add presentation (screens + state) under `lib/src/presentation/`. +4. Register the route in `lib/src/routing/app_router.dart`. +{{/if}} diff --git a/cli/templates/partials/llm/architecture-rules.hbs b/cli/templates/partials/llm/architecture-rules.hbs new file mode 100644 index 0000000..b595277 --- /dev/null +++ b/cli/templates/partials/llm/architecture-rules.hbs @@ -0,0 +1,92 @@ +## Architecture (`{{architecture}}`) + +{{#if (eq architecture "clean")}} +```text +lib/src/ +├── features// +│ ├── data/ # datasources, models, repository impls +│ ├── domain/ # entities, repo contracts, use cases +│ └── presentation/ # screens, widgets, state ({{stateManagement}}) +├── routing/ +├── services/ +├── shared/ +└── theme/ +``` + +**Rules** + +- `domain/` is pure Dart — no Flutter imports, no imports from `data/`. +- `data/` implements contracts from `domain/` — never the reverse. +- Entities are not models — no `fromJson`/`toJson` on domain entities. +- Use cases expose a single responsibility (typically one public `call()`). +- `presentation/` talks to use cases or state layer — not datasources directly. +{{/if}} + +{{#if (eq architecture "feature-first")}} +```text +lib/src/ +├── features// # model, service, state, view per feature +├── shared/ # cross-feature widgets & utils only +├── routing/ +├── services/ +└── theme/ +``` + +**Rules** + +- Each feature is self-contained — no imports from another feature's internals. +- Shared code lives only under `lib/src/shared/`. +{{/if}} + +{{#if (eq architecture "mvc")}} +```text +lib/src/ +├── controllers/ # state & orchestration ({{stateManagement}}) +├── models/ +├── services/ # repositories & API access +├── views/ # screens & widgets +├── routing/ +└── theme/ +``` + +**Rules** + +- Views only talk to controllers — no direct service calls from widgets. +- Controllers orchestrate services — no Flutter widget imports in controllers. +- One controller per screen or cohesive flow. +{{/if}} + +{{#if (eq architecture "mvvm")}} +```text +lib/src/ +├── ui/ # screens, widgets, state ({{stateManagement}}) +├── data/ # models, repository impls +├── routing/ +├── services/ +└── theme/ +``` + +**Rules** + +- UI layer binds to view models / providers / controllers under `ui/`. +- Data access goes through repositories in `data/` or `services/`. +- No business logic in widget `build()` methods. +{{/if}} + +{{#if (eq architecture "layer-first")}} +```text +lib/src/ +├── presentation/ # screens, providers/blocs, widgets +├── domain/ # entities, repo contracts +├── data/ # models, datasources, repository impls +├── routing/ +├── services/ +└── theme/ +``` + +**Rules** + +- `domain/` has no Flutter or `data/` imports. +- `data/` implements `domain/` contracts only. +- `presentation/` depends on domain abstractions — not concrete datasources. +{{/if}} diff --git a/cli/templates/partials/llm/backend-rules.hbs b/cli/templates/partials/llm/backend-rules.hbs new file mode 100644 index 0000000..f4a0efa --- /dev/null +++ b/cli/templates/partials/llm/backend-rules.hbs @@ -0,0 +1,30 @@ +## Backend ({{backend.provider}}) + +{{#if flags.usesFirebase}} +- All Firebase access goes through `lib/src/services/` and repository/datasource layers — never from widgets. +- Use `FirebaseAuth` / SDK instances via existing app initialization in `lib/main.dart` — do not re-initialize Firebase in features. +{{#if flags.usesFirebaseAuth}}- Auth: email/Google/phone options are pre-wired per generator config.{{/if}} +{{#if flags.usesFirebaseFirestore}}- Firestore: keep security rules enabled; map errors to domain failures in the data layer.{{/if}} +{{#if flags.usesFirebaseStorage}}- Storage: upload/download via repository or service abstractions.{{/if}} +- Native config: place `google-services.json` and `GoogleService-Info.plist` per **SETUP.md** — do not commit secrets. +{{/if}} + +{{#if flags.usesSupabase}} +- All Supabase calls go through services/repositories — not from UI. +- Assume Row Level Security on tables — never bypass RLS in client code. +- Session persistence is handled by the SDK — avoid manual token caching. +- Configure URL and anon key via `.env` / **SETUP.md** when `usesDotenv` is enabled. +{{/if}} + +{{#if flags.usesAppwrite}} +- Use the configured Appwrite client from app config/services — do not instantiate ad hoc clients in widgets. +- Auth and database flags follow generator options — extend services, not screens. +{{/if}} + +{{#if flags.usesCustomBackend}} +- HTTP/Dio client is configured in `lib/src/config/app_config.dart`. +- API calls belong in `lib/src/services/` (e.g. auth service) using the shared client. +- Base URL comes from environment / config — see **SETUP.md**. +{{/if}} + +For console setup (keys, native files, env), follow **[SETUP.md](SETUP.md)** — do not duplicate platform steps here. diff --git a/cli/templates/partials/llm/build-runner-note.hbs b/cli/templates/partials/llm/build-runner-note.hbs new file mode 100644 index 0000000..8c90e1c --- /dev/null +++ b/cli/templates/partials/llm/build-runner-note.hbs @@ -0,0 +1,15 @@ +{{#if (or flags.isMobX (eq flags.routerPackage "auto_route") flags.usesHive flags.usesFirebase)}} +### Code generation + +This stack uses `build_runner` ({{#if flags.usesHive}}Hive, {{/if}}{{#if flags.isMobX}}MobX, {{/if}}{{#if (eq flags.routerPackage "auto_route")}}Auto Route, {{/if}}{{#if flags.usesFirebase}}Firebase, {{/if}}etc.): + +```bash +dart run build_runner build --delete-conflicting-outputs +``` + +Re-run after changing generated routes, MobX stores, Hive adapters, or Firebase-related generated code. +{{else}} +### Code generation + +No `build_runner` step is required for the selected stack. +{{/if}} diff --git a/cli/templates/partials/llm/design-quick-ref.hbs b/cli/templates/partials/llm/design-quick-ref.hbs new file mode 100644 index 0000000..8e6e27e --- /dev/null +++ b/cli/templates/partials/llm/design-quick-ref.hbs @@ -0,0 +1,14 @@ +## Design system (summary) + +Full reference: **[DESIGN.md](DESIGN.md)** + +- **Theme:** `lib/src/theme/theme.dart` — global `ThemeData` only; no per-widget theme overrides. +- **Preset:** `{{theme.preset}}`{{#if flags.isCupertino}} (Cupertino){{/if}}{{#if theme.primaryColor}} · seed `{{theme.primaryColor}}`{{/if}} +- **Dark mode:** {{#if flags.hasDarkMode}}on{{#if theme.darkMode.system}} (system){{/if}}{{else}}off (light only){{/if}} +- **Colors:** `context.colors` (Material roles), `context.appColors` (`success`, `warning`, `info`) +- **Typography:** `context.textTheme` / `context.typography` — no raw `fontFamily` in widgets +{{#if flags.hasCustomFonts}}- **Fonts:** primary `{{flags.primaryFontFamily}}` (see `lib/src/theme/text_theme.dart`){{/if}} +- **Tokens:** `AppSpacing`, `AppBorders`, `AppShadows`, `AppDurations`, `AppCurves` from `lib/src/theme/theme_constants.dart` +- **Extensions:** `context_extension.dart` — `width`, `height`, `isDarkMode`, `showAppDialog`, `showTypedSnackBar` +{{#if flags.usesScreenutil}}- **ScreenUtil:** baseline 390×844; `.w` `.h` `.r` `.sp`; wrapper already in app — no second `ScreenUtilInit`{{else}}- **Layout:** `MediaQuery` / `LayoutBuilder` / `Flexible` — no ScreenUtil{{/if}} +- **Widgets:** reusable UI in `lib/src/shared/widgets/`; use tokens not magic numbers diff --git a/cli/templates/partials/llm/design-quick-reference.hbs b/cli/templates/partials/llm/design-quick-reference.hbs new file mode 100644 index 0000000..3f20fdb --- /dev/null +++ b/cli/templates/partials/llm/design-quick-reference.hbs @@ -0,0 +1,14 @@ +## Design system (summary) + +Full reference: **[DESIGN.md](DESIGN.md)** + +- **Theme:** `lib/src/theme/theme.dart` — no per-widget `ThemeData` overrides +- **Tokens:** `package:{{flags.appSlug}}/src/theme/theme_constants.dart` → `AppSpacing`, `AppBorders`, `AppShadows`, `AppDurations`, `AppCurves` +- **Colors:** `context.colors` (Material roles); `context.appColors` (`success`, `warning`, `info` in `color_schemes.dart`) +- **Typography:** `context.textTheme` / `context.typography` — no raw `fontFamily` in widgets +{{#if theme.primaryColor}}- **Seed color:** `{{theme.primaryColor}}`{{/if}} +{{#if flags.hasCustomFonts}}- **Primary font:** {{flags.primaryFontFamily}}{{/if}} +{{#if flags.hasDarkMode}}- **Dark mode:** enabled{{#if theme.darkMode.system}} (system){{/if}} — use semantic colors, not `Colors.white`/`Colors.black`{{/if}} +{{#if flags.usesScreenutil}}- **ScreenUtil:** baseline 390×844; `.w` `.h` `.r` `.sp`; single `ScreenUtilInit` in app wrapper{{else}}- **Layout:** `MediaQuery` / `LayoutBuilder` / `Flexible` — no ScreenUtil{{/if}} +- **Extensions:** `lib/src/extensions/context_extension.dart` — `showAppDialog`, `showTypedSnackBar`, `width`/`height` +- **Widgets:** reusable UI in `lib/src/shared/widgets/` only diff --git a/cli/templates/partials/llm/navigation-rules.hbs b/cli/templates/partials/llm/navigation-rules.hbs new file mode 100644 index 0000000..278e6f6 --- /dev/null +++ b/cli/templates/partials/llm/navigation-rules.hbs @@ -0,0 +1,27 @@ +## Navigation + +{{#if (eq flags.routerPackage "go_router")}} +- All routes are defined in `lib/src/routing/app_router.dart` — do not scatter route tables elsewhere. +- Prefer typed/named routes from the central config — avoid hard-coded path strings in widgets when a named route exists. +- `context.go()` replaces the stack; `context.push()` pushes on top. +- Redirects and guards belong in the router configuration, not inside individual screens. +{{/if}} + +{{#if (eq flags.routerPackage "auto_route")}} +- Annotate routable pages with `@RoutePage()`. +- Routes are code-generated — run `build_runner` after adding, renaming, or removing pages. +- Use `context.router.push()`, `context.router.replace()`, and `context.router.pop()` for navigation. +- Route definitions live in `lib/src/routing/app_router.dart` and generated `app_router.gr.dart`. +{{/if}} + +{{#if (eq flags.routerPackage "getx")}} +- Pages and bindings are registered via `AppRouter.getPages` in `lib/src/routing/app_router.dart`. +- Use GetX navigation APIs consistent with the existing router setup. +- Do not mix in a second routing package alongside GetX pages. +{{/if}} + +{{#unless flags.routerPackage}} +- Imperative navigation uses `AppRouter.onGenerateRoute` in `lib/src/routing/app_router.dart`. +- Use `Navigator` APIs / extension helpers on `BuildContext` from `context_extension.dart`. +- Register new screens in the central router — not ad hoc `MaterialPageRoute` factories in widgets. +{{/unless}} diff --git a/cli/templates/partials/llm/networking-rules.hbs b/cli/templates/partials/llm/networking-rules.hbs new file mode 100644 index 0000000..0646b0a --- /dev/null +++ b/cli/templates/partials/llm/networking-rules.hbs @@ -0,0 +1,16 @@ +## Networking + +{{#if flags.usesDio}} +- Use the shared `Dio` instance from `lib/src/config/app_config.dart` — never `Dio()` inside a widget or screen. +- Extend or use existing services under `lib/src/services/` for HTTP calls. +- Map transport errors to `Failure` / `FutureEither` results via `runTask()` — do not leak raw `DioException` to UI. +{{/if}} + +{{#if (and flags.usesHttp (not flags.usesDio))}} +- Use the HTTP service exported from `lib/src/services/` — do not call `http.get` directly from presentation code. +- Handle timeouts and socket errors explicitly in the service layer. +{{/if}} + +{{#unless (or flags.usesDio flags.usesHttp)}} +- No HTTP client package is enabled — use mock/local data patterns already in the template or add Dio/HTTP via `pubspec.yaml` deliberately. +{{/unless}} diff --git a/cli/templates/partials/llm/packages-list.hbs b/cli/templates/partials/llm/packages-list.hbs new file mode 100644 index 0000000..e073876 --- /dev/null +++ b/cli/templates/partials/llm/packages-list.hbs @@ -0,0 +1,26 @@ +## Key packages + +{{#if (eq flags.routerPackage "go_router")}}- `go_router` — declarative routing{{/if}} +{{#if (eq flags.routerPackage "auto_route")}}- `auto_route` — code-generated routing{{/if}} +{{#if flags.isGetX}}- `get` — state & navigation{{/if}} +{{#if flags.isRiverpod}}- `flutter_riverpod` — state management{{/if}} +{{#if flags.isProvider}}- `provider` — state management{{/if}} +{{#if flags.isBloc}}- `flutter_bloc` — state management{{/if}} +{{#if flags.isMobX}}- `mobx`, `flutter_mobx` — state management{{/if}} +{{#if flags.usesFirebase}}- `firebase_core`{{#if flags.usesFirebaseAuth}}, `firebase_auth`{{/if}}{{#if flags.usesFirebaseFirestore}}, `cloud_firestore`{{/if}}{{#if flags.usesFirebaseStorage}}, `firebase_storage`{{/if}}{{/if}} +{{#if flags.usesSupabase}}- `supabase_flutter`{{/if}} +{{#if flags.usesAppwrite}}- `appwrite`{{/if}} +{{#if flags.usesDio}}- `dio`{{/if}} +{{#if (and flags.usesHttp (not flags.usesDio))}}- `http`{{/if}} +{{#if flags.usesHive}}- `hive`, `hive_flutter`{{/if}} +{{#if flags.usesSharedPreferences}}- `shared_preferences`{{/if}} +{{#if flags.usesSecureStorage}}- `flutter_secure_storage`{{/if}} +{{#if flags.usesCachedNetworkImage}}- `cached_network_image`{{/if}} +{{#if flags.usesGeolocator}}- `geolocator`{{/if}} +{{#if flags.usesImagePicker}}- `image_picker`{{/if}} +{{#if flags.usesFilePicker}}- `file_picker`{{/if}} +{{#if flags.supportsLocalization}}- `easy_localization`{{/if}} +{{#if flags.usesScreenutil}}- `flutter_screenutil`{{/if}} +{{#if flags.usesFlutterHooks}}- `flutter_hooks`{{/if}} +{{#if flags.usesDotenv}}- `flutter_dotenv`{{/if}} +{{#if flags.usesSkeletonizer}}- `skeletonizer`{{/if}} diff --git a/cli/templates/partials/llm/services-conventions.hbs b/cli/templates/partials/llm/services-conventions.hbs new file mode 100644 index 0000000..1abc871 --- /dev/null +++ b/cli/templates/partials/llm/services-conventions.hbs @@ -0,0 +1,20 @@ +## Services & shared conventions + +- Services are singletons with `ClassName.instance`, returning `FutureEither` via `runTask()`. +- Never pass `BuildContext` into services — use `rootContext` from the global navigator helper when UI is required. +- Logging: `AppLogger`; user feedback: `showGlobalToast()`; dialogs: `showAppDialog()` / `context.showAppDialog()`. +- Primary import barrel: `package:{{flags.appSlug}}/src/imports/imports.dart`. +- File names: `snake_case.dart`; classes: `PascalCase`; private members: `_camelCase`. +- Prefer `const` constructors where possible; avoid `dynamic` without justification. +- No empty `catch` blocks — log, map to failure, or rethrow. +- Do not add packages that duplicate existing stack choices (routing, state, backend). + +### Dart / Flutter anti-patterns + +- No business logic in widget `build()` methods. +- No direct backend or network calls from presentation widgets. +{{#if flags.isRiverpod}}- Do not use `ref.read` inside `build()`.{{/if}} +{{#if flags.isBloc}}- Do not put heavy logic inside bloc event handlers — delegate outward.{{/if}} +{{#if flags.isProvider}}- Do not use `context.watch` inside callbacks.{{/if}} +{{#if flags.isGetX}}- Do not mix GetX routing with GoRouter/AutoRoute in the same app.{{/if}} +{{#if flags.isMobX}}- Do not mutate `@observable` fields outside `@action` methods.{{/if}} diff --git a/cli/templates/partials/llm/stack-summary.hbs b/cli/templates/partials/llm/stack-summary.hbs new file mode 100644 index 0000000..7b7aadd --- /dev/null +++ b/cli/templates/partials/llm/stack-summary.hbs @@ -0,0 +1,22 @@ +## Project + +**{{appName}}**{{#if description}} — {{description}}{{/if}} + +Generated by [FlutterInit](https://flutterinit.com). Package: `{{packageId}}`. + +### Stack + +| Area | Choice | +|------|--------| +| Architecture | `{{architecture}}` | +| State management | `{{stateManagement}}` | +| Navigation | `{{navigation}}` | +| Backend | `{{backend.provider}}` | +| Networking | {{#if flags.usesDio}}Dio{{else if flags.usesHttp}}HTTP{{else}}—{{/if}} | +| Local storage | {{#if flags.usesHive}}Hive{{/if}}{{#if (and flags.usesHive flags.usesSharedPreferences)}}, {{/if}}{{#if flags.usesSharedPreferences}}SharedPreferences{{/if}}{{#if (and (or flags.usesHive flags.usesSharedPreferences) flags.usesSecureStorage)}}, {{/if}}{{#if flags.usesSecureStorage}}Secure storage{{/if}}{{#unless (or flags.usesHive flags.usesSharedPreferences flags.usesSecureStorage)}}—{{/unless}} | +| Theme preset | `{{theme.preset}}` | +| Dark mode | {{#if flags.hasDarkMode}}enabled{{#if theme.darkMode.system}} (follows system){{/if}}{{else}}light only{{/if}} | +| ScreenUtil | {{#if flags.usesScreenutil}}yes{{else}}no{{/if}} | +| Localization | {{#if flags.supportsLocalization}}`easy_localization` ({{#each flags.supportedLocales}}{{this}}{{#unless @last}}, {{/unless}}{{/each}}){{else}}disabled{{/if}} | +| Flutter Hooks | {{#if flags.usesFlutterHooks}}enabled{{else}}disabled{{/if}} | +| Dotenv | {{#if flags.usesDotenv}}enabled{{else}}disabled{{/if}} | diff --git a/cli/templates/partials/llm/state-management-rules.hbs b/cli/templates/partials/llm/state-management-rules.hbs new file mode 100644 index 0000000..d2d680f --- /dev/null +++ b/cli/templates/partials/llm/state-management-rules.hbs @@ -0,0 +1,40 @@ +## State management ({{stateManagement}}) + +{{#if flags.isRiverpod}} +- Use `ConsumerWidget` / `ConsumerStatefulWidget` where reactive state is needed. +- Providers live next to features (see overlay paths under `presentation/providers/` or equivalent). +- `ref.watch` inside `build()`; `ref.read` inside callbacks and one-off actions — never `ref.read` in `build()`. +- Do not nest a second `ProviderScope` — one at app root is already configured. +- This project does **not** use `@riverpod` / `riverpod_generator` — define providers manually. +{{/if}} + +{{#if flags.isBloc}} +- Events and states are immutable — prefer `const` constructors. +- `BlocBuilder` for UI rebuilds; `BlocListener` for side effects; `BlocConsumer` when both are needed. +- Keep handlers thin — delegate to use cases, repositories, or services. +- One bloc per feature flow — do not share blocs across unrelated features. +{{/if}} + +{{#if flags.isProvider}} +- Call `notifyListeners()` after every state change in `ChangeNotifier`s. +- `context.watch` in `build()`; `context.read` in callbacks — never reversed. +- Scope providers at the appropriate subtree — not everything belongs at app root. +{{/if}} + +{{#if flags.isGetX}} +- Controllers extend `GetxController`; reactive fields use `.obs` with `Obx()`. +- Register controllers via bindings / route setup — avoid inline `Get.put()` inside widgets. +- `GetMaterialApp` is configured when navigation uses GetX — do not add a second material app wrapper. +{{/if}} + +{{#if flags.isMobX}} +- Annotate state with `@observable`; mutations with `@action`; derived values with `@computed`. +- Wrap reactive UI in `Observer`. +- Run `dart run build_runner build --delete-conflicting-outputs` after changing any store. +{{/if}} + +{{#if flags.isNoneState}} +- Session and feature state use manager / `ChangeNotifier` classes in architecture overlay paths. +- Keep state classes free of widget imports. +- Prefer explicit loading/error fields over silent failures. +{{/if}} diff --git a/cli/templates/partials/state_label.hbs b/cli/templates/partials/state_label.hbs new file mode 100644 index 0000000..c4d5148 --- /dev/null +++ b/cli/templates/partials/state_label.hbs @@ -0,0 +1 @@ +State: {{stateManagement}} \ No newline at end of file diff --git a/cli/tests/analytics.test.ts b/cli/tests/analytics.test.ts new file mode 100644 index 0000000..f111f16 --- /dev/null +++ b/cli/tests/analytics.test.ts @@ -0,0 +1,172 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import type { FlutterInitConfig } from '../src/config' +import { trackCliGeneration, type TrackPayload } from '../src/utils/analytics' + +const BASE_CONFIG: FlutterInitConfig = { + projectName: 'test_app', + orgName: 'com.example', + description: 'Test project description', + architecture: 'clean', + stateManager: 'riverpod', + backend: 'firebase', + navigation: 'gorouter', + themeMode: 'both', + primaryColor: '#027DFD', + outputDir: '/mock/output/dir', + + usesIconsaxPlus: false, + usesFlutterRemix: false, + usesHugeicons: false, + + usesDio: true, + usesHttp: false, + usesCachedNetworkImage: false, + + usesHive: false, + usesSharedPreferences: false, + usesSecureStorage: false, + + usesFlutterSvg: false, + usesImagePicker: false, + usesFilePicker: false, + usesCamera: false, + usesFlutterNativeSplash: false, + + usesUrlLauncher: false, + usesPathProvider: false, + usesSharePlus: false, + usesPermissionHandler: false, + usesGeolocator: false, + useLocalization: false, + usesNotifications: false, + + usesDeviceInfoPlus: false, + usesAppVersionUpdate: false, + + usesFlutterHooks: false, + usesSkeletonizer: false, + usesScreenutil: false, + usesDotenv: false, + usesLogger: false, + useMaterial3: false, +} + +describe('Telemetry Analytics Utility', () => { + let originalFetch: typeof fetch + let fetchCalls: { url: string; options: RequestInit }[] + let fetchErrorToThrow: Error | null = null + + beforeEach(() => { + originalFetch = globalThis.fetch + fetchCalls = [] + fetchErrorToThrow = null + + // Mock fetch globally + globalThis.fetch = (async (url: string | URL | Request, options?: RequestInit) => { + if (fetchErrorToThrow) { + throw fetchErrorToThrow + } + fetchCalls.push({ url: String(url), options: options || {} }) + return { + ok: true, + status: 202, + json: async () => ({ ok: true }), + } as Response + }) as typeof fetch + + // Clean env variables + delete process.env.FLUTTERINIT_API_URL + delete process.env.NEXT_PUBLIC_APP_URL + }) + + afterEach(() => { + globalThis.fetch = originalFetch + delete process.env.FLUTTERINIT_API_URL + delete process.env.NEXT_PUBLIC_APP_URL + }) + + it('correctly maps configuration fields to TrackPayload', async () => { + const config: FlutterInitConfig = { + ...BASE_CONFIG, + architecture: 'mvvm', + stateManager: 'bloc', + backend: 'supabase', + navigation: 'autoroute', + themeMode: 'dark', + usesDio: false, + usesHttp: true, + useLocalization: true, + usesPermissionHandler: true, + usesGeolocator: true, + usesFilePicker: true, + usesLogger: true, + usesImagePicker: true, + usesSharePlus: true, + } + + await trackCliGeneration(config) + + expect(fetchCalls.length).toBe(1) + expect(fetchCalls[0].url).toBe('https://flutterinit.com/api/track') + + const body: TrackPayload = JSON.parse(fetchCalls[0].options.body as string) + expect(body.session_id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i) + expect(body.architecture).toBe('mvvm') + expect(body.state_mgmt).toBe('bloc') + expect(body.backend_provider).toBe('supabase') + expect(body.navigation).toBe('auto_route') + expect(body.networking).toBe('http') + expect(body.dark_mode).toBe(true) + expect(body.features).toContain('localization') + expect(body.features).toContain('permissions') + expect(body.features).toContain('geolocation') + expect(body.features).toContain('file_picker') + expect(body.features).toContain('logger') + expect(body.features).toContain('image_picker') + expect(body.features).toContain('share_plus') + }) + + it('correctly defaults navigation, networking, and dark mode features', async () => { + const config: FlutterInitConfig = { + ...BASE_CONFIG, + navigation: 'none', + usesDio: false, + usesHttp: false, + themeMode: 'light', + } + + await trackCliGeneration(config) + + expect(fetchCalls.length).toBe(1) + const body: TrackPayload = JSON.parse(fetchCalls[0].options.body as string) + expect(body.navigation).toBe('imperative') + expect(body.networking).toBe('none') + expect(body.dark_mode).toBe(false) + expect(body.features.length).toBe(0) + }) + + it('respects FLUTTERINIT_API_URL and NEXT_PUBLIC_APP_URL environment overrides', async () => { + process.env.FLUTTERINIT_API_URL = 'http://localhost:3000/' + await trackCliGeneration(BASE_CONFIG) + expect(fetchCalls[0].url).toBe('http://localhost:3000/api/track') + + fetchCalls = [] + delete process.env.FLUTTERINIT_API_URL + process.env.NEXT_PUBLIC_APP_URL = 'https://dev.flutterinit.com' + await trackCliGeneration(BASE_CONFIG) + expect(fetchCalls[0].url).toBe('https://dev.flutterinit.com/api/track') + }) + + it('fails gracefully when fetch rejects (offline or server error)', async () => { + fetchErrorToThrow = new Error('Network error') + + // Should not throw or crash the CLI + await expect(trackCliGeneration(BASE_CONFIG)).resolves.toBeUndefined() + }) + + it('supplements an AbortSignal to prevent hanging', async () => { + await trackCliGeneration(BASE_CONFIG) + expect(fetchCalls[0].options.signal).toBeDefined() + expect(fetchCalls[0].options.signal instanceof AbortSignal).toBe(true) + }) +}) diff --git a/cli/tests/generator.test.ts b/cli/tests/generator.test.ts new file mode 100644 index 0000000..ca13918 --- /dev/null +++ b/cli/tests/generator.test.ts @@ -0,0 +1,209 @@ +// ───────────────────────────────────────────────────────────────────────────── +// FlutterInit CLI — Generator Unit Tests +// Tests the fs utilities and template rendering logic independently. +// Integration tests (actual flutter create) require Flutter installed. +// ───────────────────────────────────────────────────────────────────────────── + +import { afterAll, describe, expect, it } from 'bun:test' +import fs from 'fs' +import os from 'os' +import path from 'path' +import type { FlutterInitConfig } from '../src/config' +import { buildTemplateContext, renderTemplate } from '../src/templates' +import { copyDir, createGitkeep, isDirNonEmpty, removeDir, writeFile } from '../src/utils/fs' + +// ── Test fixture config ──────────────────────────────────────────────────── + +const FIXTURE_CONFIG: FlutterInitConfig = { + projectName: 'test_app', + orgName: 'com.example', + description: 'Test project', + architecture: 'clean', + stateManager: 'riverpod', + backend: 'firebase', + navigation: 'gorouter', + themeMode: 'both', + primaryColor: '#027DFD', + outputDir: path.join(os.tmpdir(), 'flutterinit_test_output'), + + // Icons + usesIconsaxPlus: true, + usesFlutterRemix: false, + usesHugeicons: false, + + // Networking + usesDio: true, + usesHttp: false, + usesCachedNetworkImage: true, + + // Persistence + usesHive: false, + usesSharedPreferences: true, + usesSecureStorage: true, + + // Media & Assets + usesFlutterSvg: true, + usesImagePicker: true, + usesFilePicker: true, + usesFlutterNativeSplash: true, + + // Essential Utilities + usesUrlLauncher: true, + usesPathProvider: true, + usesSharePlus: true, + usesPermissionHandler: true, + usesGeolocator: true, + useLocalization: true, + + // Device & System + usesDeviceInfoPlus: true, + usesAppVersionUpdate: true, + + // Advanced Features + usesFlutterHooks: false, + usesSkeletonizer: true, + usesScreenutil: true, + usesDotenv: true, + usesLogger: true, + useMaterial3: true, + usesCamera: false, + usesNotifications: false +} + +// ── FS utility tests ─────────────────────────────────────────────────────── + +describe('FS utilities', () => { + const tmpDir = path.join(os.tmpdir(), 'flutterinit_fs_test_' + Date.now()) + + afterAll(() => removeDir(tmpDir)) + + it('writeFile creates file and parent directories', () => { + const filePath = path.join(tmpDir, 'nested', 'dir', 'test.txt') + writeFile(filePath, 'hello world') + expect(fs.existsSync(filePath)).toBe(true) + expect(fs.readFileSync(filePath, 'utf-8')).toBe('hello world') + }) + + it('createGitkeep creates dir with .gitkeep', () => { + const dirPath = path.join(tmpDir, 'gitkeep_test') + createGitkeep(dirPath) + expect(fs.existsSync(path.join(dirPath, '.gitkeep'))).toBe(true) + }) + + it('isDirNonEmpty returns false for non-existent dir', () => { + expect(isDirNonEmpty(path.join(tmpDir, 'does_not_exist'))).toBe(false) + }) + + it('isDirNonEmpty returns true for non-empty dir', () => { + const nonEmpty = path.join(tmpDir, 'non_empty') + fs.mkdirSync(nonEmpty, { recursive: true }) + fs.writeFileSync(path.join(nonEmpty, 'file.txt'), 'data') + expect(isDirNonEmpty(nonEmpty)).toBe(true) + }) + + it('isDirNonEmpty returns false for empty dir', () => { + const emptyDir = path.join(tmpDir, 'empty') + fs.mkdirSync(emptyDir, { recursive: true }) + expect(isDirNonEmpty(emptyDir)).toBe(false) + }) + + it('removeDir silently handles non-existent dirs', () => { + expect(() => removeDir(path.join(tmpDir, 'non_existent'))).not.toThrow() + }) + + it('removeDir removes existing directory', () => { + const toRemove = path.join(tmpDir, 'to_remove') + fs.mkdirSync(toRemove, { recursive: true }) + fs.writeFileSync(path.join(toRemove, 'file.txt'), 'data') + removeDir(toRemove) + expect(fs.existsSync(toRemove)).toBe(false) + }) + + it('copyDir copies files and subdirectories', () => { + const src = path.join(tmpDir, 'copy_src') + const dest = path.join(tmpDir, 'copy_dest') + fs.mkdirSync(path.join(src, 'subdir'), { recursive: true }) + fs.writeFileSync(path.join(src, 'file.txt'), 'hello') + fs.writeFileSync(path.join(src, 'subdir', 'nested.txt'), 'nested') + copyDir(src, dest) + expect(fs.existsSync(path.join(dest, 'file.txt'))).toBe(true) + expect(fs.existsSync(path.join(dest, 'subdir', 'nested.txt'))).toBe(true) + }) +}) + +// ── Template context tests ───────────────────────────────────────────────── + +describe('Template context', () => { + it('spreads all config fields onto context', () => { + const ctx = buildTemplateContext(FIXTURE_CONFIG) + expect(ctx.projectName).toBe('test_app') + expect(ctx.primaryColor).toBe('#027DFD') + expect(ctx.useMaterial3).toBe(true) + expect(ctx.useLocalization).toBe(true) + }) + + it('has no undefined derived booleans', () => { + const ctx = buildTemplateContext(FIXTURE_CONFIG) + const boolFields = [ + 'isRiverpod', 'isBloc', 'isProvider', 'isMobX', 'isGetX', + 'isCleanArch', 'isMvvm', 'isFeatureFirst', 'isMvc', 'isLayerFirst', + 'hasFirebase', 'hasSupabase', 'hasAppwrite', 'hasBackend', + 'hasGoRouter', 'hasAutoRoute', 'hasNavigation', + 'hasDarkMode', 'hasLightMode', 'hasBothModes', + ] + for (const field of boolFields) { + expect(ctx[field as keyof typeof ctx]).not.toBeUndefined() + } + }) +}) + +// ── Template rendering tests ─────────────────────────────────────────────── + +describe('Template rendering', () => { + it('renders pubspec.yaml.hbs without throwing', () => { + expect(() => + renderTemplate('base/pubspec.yaml.hbs', FIXTURE_CONFIG) + ).not.toThrow() + }) + + it('rendered pubspec contains project name', () => { + const output = renderTemplate('base/pubspec.yaml.hbs', FIXTURE_CONFIG) + expect(output).toContain('test_app') + }) + + it('renders main.dart.hbs without throwing', () => { + expect(() => + renderTemplate('base/lib/main.dart.hbs', FIXTURE_CONFIG) + ).not.toThrow() + }) + + it('renders AGENTS.md.hbs without throwing', () => { + expect(() => + renderTemplate('base/AGENTS.md.hbs', FIXTURE_CONFIG) + ).not.toThrow() + }) + + it('renders DESIGN.md.hbs without throwing', () => { + expect(() => + renderTemplate('base/DESIGN.md.hbs', FIXTURE_CONFIG) + ).not.toThrow() + }) + + it('does not throw for all 5 architecture configs', () => { + const architectures = ['clean', 'mvvm', 'feature-first', 'mvc', 'layer-first'] as const + for (const arch of architectures) { + expect(() => + renderTemplate('base/pubspec.yaml.hbs', { ...FIXTURE_CONFIG, architecture: arch }) + ).not.toThrow() + } + }) + + it('does not throw for all 5 state managers', () => { + const managers = ['riverpod', 'bloc', 'provider', 'mobx', 'getx'] as const + for (const sm of managers) { + expect(() => + renderTemplate('base/lib/main.dart.hbs', { ...FIXTURE_CONFIG, stateManager: sm }) + ).not.toThrow() + } + }) +}) diff --git a/cli/tests/native.test.ts b/cli/tests/native.test.ts new file mode 100644 index 0000000..941e30b --- /dev/null +++ b/cli/tests/native.test.ts @@ -0,0 +1,272 @@ +// ───────────────────────────────────────────────────────────────────────────── +// native.test.ts — Unit tests for buildAndroidPermissions & buildIosPlistEntries +// Uses Bun's test runner. No file system access — all tests use mock configs. +// ───────────────────────────────────────────────────────────────────────────── + +import { describe, expect, it } from 'bun:test' +import type { FlutterInitConfig } from '../src/config' +import { buildAndroidPermissions, buildIosPlistEntries } from '../src/native' + +// ─── Base config fixture ────────────────────────────────────────────────────── + +const baseConfig: FlutterInitConfig = { + projectName: 'test_app', + orgName: 'com.example', + description: 'Test app', + architecture: 'clean', + stateManager: 'riverpod', + backend: 'none', + navigation: 'gorouter', + themeMode: 'both', + primaryColor: '#027DFD', + outputDir: '/tmp/test_app', + + // Icons + usesIconsaxPlus: false, + usesFlutterRemix: false, + usesHugeicons: false, + + // Networking + usesDio: false, + usesHttp: false, + usesCachedNetworkImage: false, + + // Persistence + usesHive: false, + usesSharedPreferences: false, + usesSecureStorage: false, + + // Media & Assets + usesFlutterSvg: false, + usesImagePicker: false, + usesCamera: false, + usesFilePicker: false, + + // Essential Utilities + usesUrlLauncher: false, + usesPathProvider: false, + usesSharePlus: false, + usesPermissionHandler: false, + usesGeolocator: false, + useLocalization: false, + usesNotifications: false, + + // Device & System + usesDeviceInfoPlus: false, + usesAppVersionUpdate: false, + usesFlutterNativeSplash: false, + + // Advanced Features + usesFlutterHooks: false, + usesSkeletonizer: false, + usesScreenutil: false, + usesDotenv: false, + usesLogger: false, + useMaterial3: false, +} + +// ─── buildAndroidPermissions ────────────────────────────────────────────────── + +describe('buildAndroidPermissions', () => { + it('returns empty array when no native features are selected', () => { + const result = buildAndroidPermissions(baseConfig) + expect(result).toHaveLength(0) + }) + + it('adds CAMERA + READ/WRITE_EXTERNAL_STORAGE when usesCamera is true', () => { + const config = { ...baseConfig, usesCamera: true } + const result = buildAndroidPermissions(config) + expect(result).toContain('android.permission.CAMERA') + expect(result).toContain('android.permission.READ_EXTERNAL_STORAGE') + expect(result).toContain('android.permission.WRITE_EXTERNAL_STORAGE') + }) + + it('adds CAMERA + READ/WRITE_EXTERNAL_STORAGE when usesImagePicker is true', () => { + const config = { ...baseConfig, usesImagePicker: true } + const result = buildAndroidPermissions(config) + expect(result).toContain('android.permission.CAMERA') + expect(result).toContain('android.permission.READ_EXTERNAL_STORAGE') + }) + + it('adds only READ_EXTERNAL_STORAGE for usesFilePicker alone', () => { + const config = { ...baseConfig, usesFilePicker: true } + const result = buildAndroidPermissions(config) + expect(result).toContain('android.permission.READ_EXTERNAL_STORAGE') + expect(result).not.toContain('android.permission.CAMERA') + }) + + it('adds location permissions when usesGeolocator is true', () => { + const config = { ...baseConfig, usesGeolocator: true } + const result = buildAndroidPermissions(config) + expect(result).toContain('android.permission.ACCESS_FINE_LOCATION') + expect(result).toContain('android.permission.ACCESS_COARSE_LOCATION') + }) + + it('adds notification permissions including POST_NOTIFICATIONS when usesNotifications is true', () => { + const config = { ...baseConfig, usesNotifications: true } + const result = buildAndroidPermissions(config) + expect(result).toContain('android.permission.RECEIVE_BOOT_COMPLETED') + expect(result).toContain('android.permission.VIBRATE') + expect(result).toContain('android.permission.WAKE_LOCK') + expect(result).toContain('android.permission.POST_NOTIFICATIONS') + }) + + it('deduplicates READ_EXTERNAL_STORAGE when camera and filePicker are both selected', () => { + const config = { ...baseConfig, usesCamera: true, usesFilePicker: true } + const result = buildAndroidPermissions(config) + // Raw result may contain duplicates — callers use new Set() but the + // builder itself returns the raw list; Set dedup happens in configureAndroid. + // Verify that the permission appears at least once. + const count = result.filter(p => p === 'android.permission.READ_EXTERNAL_STORAGE').length + expect(count).toBeGreaterThanOrEqual(1) + }) + + it('combines all permissions when all features are selected', () => { + const config = { + ...baseConfig, + usesCamera: true, + usesImagePicker: true, + usesFilePicker: true, + usesGeolocator: true, + usesNotifications: true, + } + const result = buildAndroidPermissions(config) + expect(result).toContain('android.permission.CAMERA') + expect(result).toContain('android.permission.ACCESS_FINE_LOCATION') + expect(result).toContain('android.permission.POST_NOTIFICATIONS') + expect(result).toContain('android.permission.VIBRATE') + }) +}) + +// ─── buildIosPlistEntries ───────────────────────────────────────────────────── + +describe('buildIosPlistEntries', () => { + it('returns empty array when no native features are selected', () => { + const result = buildIosPlistEntries(baseConfig) + expect(result).toHaveLength(0) + }) + + it('adds NSCameraUsageDescription and photo library entries when usesCamera is true', () => { + const config = { ...baseConfig, usesCamera: true } + const result = buildIosPlistEntries(config) + expect(result.join('\n')).toContain('NSCameraUsageDescription') + expect(result.join('\n')).toContain('NSPhotoLibraryUsageDescription') + expect(result.join('\n')).toContain('NSPhotoLibraryAddUsageDescription') + }) + + it('adds NSCameraUsageDescription and photo library entries when usesImagePicker is true', () => { + const config = { ...baseConfig, usesImagePicker: true } + const result = buildIosPlistEntries(config) + expect(result.join('\n')).toContain('NSCameraUsageDescription') + expect(result.join('\n')).toContain('NSPhotoLibraryUsageDescription') + }) + + it('adds only NSPhotoLibraryUsageDescription for usesFilePicker alone', () => { + const config = { ...baseConfig, usesFilePicker: true } + const result = buildIosPlistEntries(config) + expect(result.join('\n')).toContain('NSPhotoLibraryUsageDescription') + expect(result.join('\n')).not.toContain('NSCameraUsageDescription') + }) + + it('adds location usage strings when usesGeolocator is true', () => { + const config = { ...baseConfig, usesGeolocator: true } + const result = buildIosPlistEntries(config) + expect(result.join('\n')).toContain('NSLocationWhenInUseUsageDescription') + expect(result.join('\n')).toContain('NSLocationAlwaysAndWhenInUseUsageDescription') + }) + + it('adds no plist entries for usesNotifications alone (handled at runtime)', () => { + const config = { ...baseConfig, usesNotifications: true } + const result = buildIosPlistEntries(config) + expect(result).toHaveLength(0) + }) + + it('deduplicates NSPhotoLibraryUsageDescription when camera and filePicker are both selected', () => { + const config = { ...baseConfig, usesCamera: true, usesFilePicker: true } + const result = buildIosPlistEntries(config) + const photoLibKeys = result.filter(l => l.includes('NSPhotoLibraryUsageDescription')) + // Each key tag should appear exactly once after deduplication + expect(photoLibKeys).toHaveLength(1) + }) + + it('entries alternate as / pairs (correct plist format)', () => { + const config = { ...baseConfig, usesGeolocator: true } + const result = buildIosPlistEntries(config) + for (let i = 0; i < result.length; i += 2) { + expect(result[i]).toContain('') + expect(result[i + 1]).toContain('') + } + }) + + it('each entry uses tab indentation (\\t prefix)', () => { + const config = { ...baseConfig, usesCamera: true } + const result = buildIosPlistEntries(config) + for (const line of result) { + expect(line.startsWith('\t')).toBe(true) + } + }) +}) + +// ─── Layer 2 native combination tests ───────────────────────────────────────── + +describe('Layer 2 — native combinations', () => { + it('combo: camera + location — produces correct Android permission set', () => { + const config = { ...baseConfig, usesCamera: true, usesGeolocator: true } + const permissions = buildAndroidPermissions(config) + const unique = [...new Set(permissions)] + expect(unique).toContain('android.permission.CAMERA') + expect(unique).toContain('android.permission.ACCESS_FINE_LOCATION') + expect(unique).toContain('android.permission.ACCESS_COARSE_LOCATION') + }) + + it('combo: camera + location — produces correct iOS plist entries', () => { + const config = { ...baseConfig, usesCamera: true, usesGeolocator: true } + const entries = buildIosPlistEntries(config) + const joined = entries.join('\n') + expect(joined).toContain('NSCameraUsageDescription') + expect(joined).toContain('NSLocationWhenInUseUsageDescription') + }) + + it('combo: filePicker + notifications — produces correct Android set', () => { + const config = { ...baseConfig, usesFilePicker: true, usesNotifications: true } + const permissions = buildAndroidPermissions(config) + const unique = [...new Set(permissions)] + expect(unique).toContain('android.permission.READ_EXTERNAL_STORAGE') + expect(unique).toContain('android.permission.POST_NOTIFICATIONS') + expect(unique).not.toContain('android.permission.CAMERA') + }) + + it('combo: camera + filePicker + location + notifications — all-features smoke test', () => { + const config = { + ...baseConfig, + usesCamera: true, + usesImagePicker: true, + usesFilePicker: true, + usesGeolocator: true, + usesNotifications: true, + } + + // Android + const permissions = [...new Set(buildAndroidPermissions(config))] + expect(permissions).toContain('android.permission.CAMERA') + expect(permissions).toContain('android.permission.READ_EXTERNAL_STORAGE') + expect(permissions).toContain('android.permission.ACCESS_FINE_LOCATION') + expect(permissions).toContain('android.permission.POST_NOTIFICATIONS') + + // READ_EXTERNAL_STORAGE deduplicated (appears in both camera + filePicker branches) + const readStorageCount = permissions.filter( + p => p === 'android.permission.READ_EXTERNAL_STORAGE', + ).length + expect(readStorageCount).toBe(1) + + // iOS + const entries = buildIosPlistEntries(config) + const joined = entries.join('\n') + expect(joined).toContain('NSCameraUsageDescription') + expect(joined).toContain('NSLocationWhenInUseUsageDescription') + + // NSPhotoLibraryUsageDescription deduplicated + const photoLibKeys = entries.filter(l => l.includes('NSPhotoLibraryUsageDescription')) + expect(photoLibKeys).toHaveLength(1) + }) +}) diff --git a/cli/tests/preflight.test.ts b/cli/tests/preflight.test.ts new file mode 100644 index 0000000..6412c99 --- /dev/null +++ b/cli/tests/preflight.test.ts @@ -0,0 +1,56 @@ +// ───────────────────────────────────────────────────────────────────────────── +// FlutterInit CLI — Preflight Tests +// Mocks execSync to verify correct behavior when Flutter is missing or present. +// ───────────────────────────────────────────────────────────────────────────── + +import { describe, expect, it } from 'bun:test' +import { exec } from '../src/utils/exec' + +// ── exec() utility tests ─────────────────────────────────────────────────── + +describe('exec utility', () => { + it('returns stdout as a string for a valid command', () => { + // Use a cross-platform command available everywhere + const result = exec('echo hello') + expect(result.trim()).toContain('hello') + }) + + it('throws when command exits with non-zero', () => { + expect(() => exec('exit 1')).toThrow() + }) +}) + +// ── Flutter version check simulation ────────────────────────────────────── + +describe('flutter version check', () => { + it('should parse flutter version output', () => { + // Simulate what runPreflight does with the output + const mockOutput = 'Flutter 3.22.0 • channel stable • https://github.com/flutter/flutter.git\nFramework • revision abc123 • 2024-01-01' + const versionLine = mockOutput.split('\n')[0]?.trim() ?? '' + expect(versionLine).toMatch(/^Flutter \d+\.\d+\.\d+/) + }) + + it('should extract clean version line', () => { + const mockOutput = 'Flutter 3.19.5 • channel stable\nDart version 3.3.3' + const versionLine = mockOutput.split('\n')[0]?.trim() + expect(versionLine).toBe('Flutter 3.19.5 • channel stable') + }) +}) + +// ── Bun version check simulation ────────────────────────────────────────── + +describe('bun version detection', () => { + it('recognizes valid bun 1.x versions', () => { + const validVersions = ['1.0.0', '1.1.5', '1.2.0'] + for (const v of validVersions) { + const [major] = v.split('.').map(Number) + expect(major).toBeGreaterThanOrEqual(1) + } + }) + + it('flags old bun versions as warnings', () => { + const oldVersion = '0.6.14' + const [major] = oldVersion.split('.').map(Number) + expect(major).toBeLessThan(1) + }) +}) diff --git a/cli/tests/prompts.test.ts b/cli/tests/prompts.test.ts new file mode 100644 index 0000000..16942bf Binary files /dev/null and b/cli/tests/prompts.test.ts differ diff --git a/cli/tsconfig.json b/cli/tsconfig.json new file mode 100644 index 0000000..615bd86 --- /dev/null +++ b/cli/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "./dist", + "rootDir": ".", + "resolveJsonModule": true, + "types": ["bun-types"] + }, + "include": ["bin/**/*", "src/**/*", "tests/**/*"], + "exclude": ["node_modules", "dist", "templates"] +} diff --git a/components.json b/components.json index 94811cc..2d9b493 100644 --- a/components.json +++ b/components.json @@ -11,6 +11,8 @@ "prefix": "" }, "iconLibrary": "hugeicons", + "menuColor": "default", + "menuAccent": "subtle", "aliases": { "components": "@/components", "utils": "@/lib/utils", @@ -18,7 +20,7 @@ "lib": "@/lib", "hooks": "@/hooks" }, - "menuColor": "default", - "menuAccent": "subtle", - "registries": {} + "registries": { + "@magicui": "https://magicui.design/r/{name}" + } } diff --git a/components/animate-ui/components/animate/avatar-group.tsx b/components/animate-ui/components/animate/avatar-group.tsx new file mode 100644 index 0000000..9f72f2e --- /dev/null +++ b/components/animate-ui/components/animate/avatar-group.tsx @@ -0,0 +1,72 @@ +import * as React from 'react'; +import * as motion from 'motion/react-client'; + +import { + AvatarGroup as AvatarGroupPrimitive, + AvatarGroupTooltip as AvatarGroupTooltipPrimitive, + AvatarGroupTooltipArrow as AvatarGroupTooltipArrowPrimitive, + type AvatarGroupProps as AvatarGroupPropsPrimitive, + type AvatarGroupTooltipProps as AvatarGroupTooltipPropsPrimitive, +} from '@/components/animate-ui/primitives/animate/avatar-group'; +import { cn } from '@/lib/utils'; + +type AvatarGroupProps = AvatarGroupPropsPrimitive; + +function AvatarGroup({ + className, + invertOverlap = true, + ...props +}: AvatarGroupProps) { + return ( + + ); +} + +type AvatarGroupTooltipProps = Omit< + AvatarGroupTooltipPropsPrimitive, + 'asChild' +> & { + children: React.ReactNode; + layout?: boolean | 'position' | 'size' | 'preserve-aspect'; + arrowClassName?: string; +}; + +function AvatarGroupTooltip({ + className, + arrowClassName, + children, + layout = 'preserve-aspect', + ...props +}: AvatarGroupTooltipProps) { + return ( + + + {children} + + + + ); +} + +export { + AvatarGroup, + AvatarGroupTooltip, + type AvatarGroupProps, + type AvatarGroupTooltipProps, +}; diff --git a/components/animate-ui/components/animate/code-tabs.tsx b/components/animate-ui/components/animate/code-tabs.tsx new file mode 100644 index 0000000..70bb506 --- /dev/null +++ b/components/animate-ui/components/animate/code-tabs.tsx @@ -0,0 +1,150 @@ +'use client'; + +import * as React from 'react'; +import { useTheme } from 'next-themes'; + +import { cn } from '@/lib/utils'; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, + TabsContents, + TabsHighlight, + TabsHighlightItem, + type TabsProps, +} from '@/components/animate-ui/primitives/animate/tabs'; +import { CopyButton } from '@/components/animate-ui/components/buttons/copy'; + +type CodeTabsProps = { + codes: Record; + lang?: string; + themes?: { light: string; dark: string }; + copyButton?: boolean; + onCopiedChange?: (copied: boolean, content?: string) => void; +} & Omit; + +function CodeTabs({ + codes, + lang = 'bash', + themes = { + light: 'github-light', + dark: 'github-dark', + }, + className, + defaultValue, + value, + onValueChange, + copyButton = true, + onCopiedChange, + ...props +}: CodeTabsProps) { + const { resolvedTheme } = useTheme(); + + const [highlightedCodes, setHighlightedCodes] = React.useState | null>(null); + const [selectedCode, setSelectedCode] = React.useState( + value ?? defaultValue ?? Object.keys(codes)[0] ?? '', + ); + + React.useEffect(() => { + async function loadHighlightedCode() { + try { + const { codeToHtml } = await import('shiki'); + const newHighlightedCodes: Record = {}; + + for (const [command, val] of Object.entries(codes)) { + const highlighted = await codeToHtml(val, { + lang, + themes: { + light: themes.light, + dark: themes.dark, + }, + defaultColor: resolvedTheme === 'dark' ? 'dark' : 'light', + }); + + newHighlightedCodes[command] = highlighted; + } + + setHighlightedCodes(newHighlightedCodes); + } catch (error) { + console.error('Error highlighting codes', error); + setHighlightedCodes(codes); + } + } + loadHighlightedCode(); + }, [resolvedTheme, lang, themes.light, themes.dark, codes]); + + return ( + { + setSelectedCode(val); + onValueChange?.(val); + }} + > + + +
+ {highlightedCodes && + Object.keys(highlightedCodes).map((code) => ( + + + {code} + + + ))} +
+ + {copyButton && highlightedCodes && ( + + )} +
+
+ + + {highlightedCodes && + Object.entries(highlightedCodes).map(([code, val]) => ( + +
+ + ))} + + + ); +} + +export { CodeTabs, type CodeTabsProps }; diff --git a/components/animate-ui/components/backgrounds/hexagon.tsx b/components/animate-ui/components/backgrounds/hexagon.tsx new file mode 100644 index 0000000..eb8f80c --- /dev/null +++ b/components/animate-ui/components/backgrounds/hexagon.tsx @@ -0,0 +1,105 @@ +'use client'; + +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +type HexagonBackgroundProps = React.ComponentProps<'div'> & { + hexagonProps?: React.ComponentProps<'div'>; + hexagonSize?: number; // value greater than 50 + hexagonMargin?: number; + hexagonStroke?: number; +}; + +function HexagonBackground({ + className, + children, + hexagonProps, + hexagonSize = 75, + hexagonMargin = 3, + hexagonStroke: hexagonStrokeProp, + ...props +}: HexagonBackgroundProps) { + const hexagonWidth = hexagonSize; + const hexagonHeight = hexagonSize * 1.1; + const rowSpacing = hexagonSize * 0.8; + const baseMarginTop = -36 - 0.275 * (hexagonSize - 100); + const computedMarginTop = baseMarginTop + hexagonMargin; + const oddRowMarginLeft = -(hexagonSize / 2); + const evenRowMarginLeft = hexagonMargin / 2; + // Grid gap uses hexagonMargin; stroke width is independent when hexagonStroke is set. + const hexagonStroke = + hexagonStrokeProp ?? (hexagonMargin > 0 ? hexagonMargin : 1); + + const [gridDimensions, setGridDimensions] = React.useState({ + rows: 0, + columns: 0, + }); + + const updateGridDimensions = React.useCallback(() => { + const rows = Math.ceil(window.innerHeight / rowSpacing); + const columns = Math.ceil(window.innerWidth / hexagonWidth) + 1; + setGridDimensions({ rows, columns }); + }, [rowSpacing, hexagonWidth]); + + React.useEffect(() => { + updateGridDimensions(); + window.addEventListener('resize', updateGridDimensions); + return () => window.removeEventListener('resize', updateGridDimensions); + }, [updateGridDimensions]); + + return ( +
+ +
+ {Array.from({ length: gridDimensions.rows }).map((_, rowIndex) => ( +
+ {Array.from({ length: gridDimensions.columns }).map( + (_, colIndex) => ( +
+ ), + )} +
+ ))} +
+ {children} +
+ ); +} + +export { HexagonBackground, type HexagonBackgroundProps }; diff --git a/components/animate-ui/components/buttons/copy.tsx b/components/animate-ui/components/buttons/copy.tsx new file mode 100644 index 0000000..a1b012b --- /dev/null +++ b/components/animate-ui/components/buttons/copy.tsx @@ -0,0 +1,128 @@ +'use client'; + +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { AnimatePresence, motion } from 'motion/react'; +import { CheckIcon, CopyIcon } from 'lucide-react'; + +import { + Button as ButtonPrimitive, + type ButtonProps as ButtonPrimitiveProps, +} from '@/components/animate-ui/primitives/buttons/button'; +import { cn } from '@/lib/utils'; +import { useControlledState } from '@/hooks/use-controlled-state'; + +const buttonVariants = cva( + "flex items-center justify-center rounded-md transition-[box-shadow,_color,_background-color,_border-color,_outline-color,_text-decoration-color,_fill,_stroke] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: + 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90', + accent: 'bg-accent text-accent-foreground shadow-xs hover:bg-accent/90', + destructive: + 'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60', + outline: + 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50', + secondary: + 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80', + ghost: + 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', + link: 'text-primary underline-offset-4 hover:underline', + }, + size: { + default: 'size-9', + xs: "size-7 [&_svg:not([class*='size-'])]:size-3.5 rounded-md", + sm: 'size-8 rounded-md', + lg: 'size-10 rounded-md', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + }, +); + +type CopyButtonProps = Omit & + VariantProps & { + content: string; + copied?: boolean; + onCopiedChange?: (copied: boolean, content?: string) => void; + delay?: number; + iconWrapper?: (icon: React.ReactNode) => React.ReactNode; + wrapperClassName?: string; + children?: React.ReactNode; + }; + +function CopyButton({ + className, + content, + copied, + onCopiedChange, + onClick, + variant, + size, + delay = 3000, + children, + iconWrapper, + wrapperClassName, + ...props +}: CopyButtonProps) { + const [isCopied, setIsCopied] = useControlledState({ + value: copied, + onChange: onCopiedChange, + }); + + const handleCopy = React.useCallback( + (e: React.MouseEvent) => { + onClick?.(e); + if (copied) return; + if (content) { + navigator.clipboard + .writeText(content) + .then(() => { + setIsCopied(true, content); + setTimeout(() => { + setIsCopied(false); + }, delay); + }) + .catch((error) => { + console.error('Error copying command', error); + }); + } + }, + [onClick, copied, content, setIsCopied, delay], + ); + + const Icon = isCopied ? CheckIcon : CopyIcon; + const renderedIcon = ; + const wrappedIcon = iconWrapper ? iconWrapper(renderedIcon) : renderedIcon; + + return ( + + + + {wrappedIcon} + {children} + + + + ); +} + +export { CopyButton, buttonVariants, type CopyButtonProps }; diff --git a/components/animate-ui/components/buttons/icon.tsx b/components/animate-ui/components/buttons/icon.tsx new file mode 100644 index 0000000..afdd75f --- /dev/null +++ b/components/animate-ui/components/buttons/icon.tsx @@ -0,0 +1,86 @@ +'use client'; + +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; + +import { + Button as ButtonPrimitive, + type ButtonProps as ButtonPrimitiveProps, +} from '@/components/animate-ui/primitives/buttons/button'; +import { cn } from '@/lib/utils'; +import { + Particles, + ParticlesEffect, +} from '@/components/animate-ui/primitives/effects/particles'; + +const buttonVariants = cva( + "flex items-center justify-center rounded-md transition-[box-shadow,_color,_background-color,_border-color,_outline-color,_text-decoration-color,_fill,_stroke] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: + 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90', + accent: 'bg-accent text-accent-foreground shadow-xs hover:bg-accent/90', + destructive: + 'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60', + outline: + 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50', + secondary: + 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80', + ghost: + 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', + link: 'text-primary underline-offset-4 hover:underline', + }, + size: { + default: 'size-9', + xs: "size-7 [&_svg:not([class*='size-'])]:size-3.5 rounded-md", + sm: 'size-8 rounded-md', + lg: 'size-10 rounded-md', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + }, +); + +type IconButtonProps = Omit & + VariantProps & { + children?: React.ReactNode; + }; + +function IconButton({ + className, + onClick, + variant, + size, + children, + ...props +}: IconButtonProps) { + const [isActive, setIsActive] = React.useState(false); + const [key, setKey] = React.useState(0); + + return ( + + { + setKey((prev) => prev + 1); + setIsActive(true); + onClick?.(e); + }} + {...props} + > + {children} + + + + ); +} + +export { IconButton, buttonVariants, type IconButtonProps }; diff --git a/components/animate-ui/components/buttons/theme-toggler.tsx b/components/animate-ui/components/buttons/theme-toggler.tsx new file mode 100644 index 0000000..e67cca3 --- /dev/null +++ b/components/animate-ui/components/buttons/theme-toggler.tsx @@ -0,0 +1,85 @@ +'use client'; + +import * as React from 'react'; +import { useTheme } from 'next-themes'; +import { Monitor, Moon, Sun } from 'lucide-react'; +import { VariantProps } from 'class-variance-authority'; + +import { + ThemeToggler as ThemeTogglerPrimitive, + type ThemeTogglerProps as ThemeTogglerPrimitiveProps, + type ThemeSelection, + type Resolved, +} from '@/components/animate-ui/primitives/effects/theme-toggler'; +import { buttonVariants } from '@/components/animate-ui/components/buttons/icon'; +import { cn } from '@/lib/utils'; + +const getIcon = ( + effective: ThemeSelection, + resolved: Resolved, + modes: ThemeSelection[], +) => { + const theme = modes.includes('system') ? effective : resolved; + return theme === 'system' ? ( + + ) : theme === 'dark' ? ( + + ) : ( + + ); +}; + +const getNextTheme = ( + effective: ThemeSelection, + modes: ThemeSelection[], +): ThemeSelection => { + const i = modes.indexOf(effective); + if (i === -1) return modes[0]; + return modes[(i + 1) % modes.length]; +}; + +type ThemeTogglerButtonProps = React.ComponentProps<'button'> & + VariantProps & { + modes?: ThemeSelection[]; + onImmediateChange?: ThemeTogglerPrimitiveProps['onImmediateChange']; + direction?: ThemeTogglerPrimitiveProps['direction']; + }; + +function ThemeTogglerButton({ + variant = 'default', + size = 'default', + modes = ['light', 'dark', 'system'], + direction = 'ltr', + onImmediateChange, + onClick, + className, + ...props +}: ThemeTogglerButtonProps) { + const { theme, resolvedTheme, setTheme } = useTheme(); + + return ( + + {({ effective, resolved, toggleTheme }) => ( + + )} + + ); +} + +export { ThemeTogglerButton, type ThemeTogglerButtonProps }; diff --git a/components/animate-ui/components/radix/sidebar.tsx b/components/animate-ui/components/radix/sidebar.tsx new file mode 100644 index 0000000..c3c576f --- /dev/null +++ b/components/animate-ui/components/radix/sidebar.tsx @@ -0,0 +1,810 @@ +'use client'; + +import * as React from 'react'; +import { Slot } from 'radix-ui'; +import { cva, VariantProps } from 'class-variance-authority'; +import { PanelLeftIcon } from 'lucide-react'; +import { type Transition } from 'motion/react'; + +import { useIsMobile } from '@/hooks/use-mobile'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Separator } from '@/components/ui/separator'; +import { Skeleton } from '@/components/ui/skeleton'; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from '@/components/ui/sheet'; +import { + TooltipProvider, + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { + Highlight, + HighlightItem, +} from '@/components/animate-ui/primitives/effects/highlight'; +import { getStrictContext } from '@/lib/get-strict-context'; + +const SIDEBAR_COOKIE_NAME = 'sidebar_state'; +const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; +const SIDEBAR_WIDTH = '16rem'; +const SIDEBAR_WIDTH_MOBILE = '18rem'; +const SIDEBAR_WIDTH_ICON = '3rem'; +const SIDEBAR_KEYBOARD_SHORTCUT = 'b'; + +type SidebarContextProps = { + state: 'expanded' | 'collapsed'; + open: boolean; + setOpen: (open: boolean) => void; + openMobile: boolean; + setOpenMobile: (open: boolean) => void; + isMobile: boolean; + toggleSidebar: () => void; +}; + +const [LocalSidebarProvider, useSidebar] = + getStrictContext('SidebarContext'); + +type SidebarProviderProps = React.ComponentProps<'div'> & { + defaultOpen?: boolean; + open?: boolean; + onOpenChange?: (open: boolean) => void; +}; + +function SidebarProvider({ + defaultOpen = true, + open: openProp, + onOpenChange: setOpenProp, + className, + style, + children, + ...props +}: SidebarProviderProps) { + const isMobile = useIsMobile(); + const [openMobile, setOpenMobile] = React.useState(false); + + // This is the internal state of the sidebar. + // We use openProp and setOpenProp for control from outside the component. + const [_open, _setOpen] = React.useState(defaultOpen); + const open = openProp ?? _open; + const setOpen = React.useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + const openState = typeof value === 'function' ? value(open) : value; + if (setOpenProp) { + setOpenProp(openState); + } else { + _setOpen(openState); + } + + // This sets the cookie to keep the sidebar state. + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; + }, + [setOpenProp, open], + ); + + // Helper to toggle the sidebar. + const toggleSidebar = React.useCallback(() => { + return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open); + }, [isMobile, setOpen, setOpenMobile]); + + // Adds a keyboard shortcut to toggle the sidebar. + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if ( + event.key === SIDEBAR_KEYBOARD_SHORTCUT && + (event.metaKey || event.ctrlKey) + ) { + event.preventDefault(); + toggleSidebar(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [toggleSidebar]); + + // We add a state so that we can do data-state="expanded" or "collapsed". + // This makes it easier to style the sidebar with Tailwind classes. + const state = open ? 'expanded' : 'collapsed'; + + const contextValue = React.useMemo( + () => ({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar, + }), + [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar], + ); + + return ( + + +
+ {children} +
+
+
+ ); +} + +type SidebarProps = React.ComponentProps<'div'> & { + side?: 'left' | 'right'; + variant?: 'sidebar' | 'floating' | 'inset'; + collapsible?: 'offcanvas' | 'icon' | 'none'; + containerClassName?: string; + animateOnHover?: boolean; + transition?: Transition; +}; + +function Sidebar({ + side = 'left', + variant = 'sidebar', + collapsible = 'offcanvas', + className, + children, + animateOnHover = true, + containerClassName, + transition = { type: 'spring', stiffness: 350, damping: 35 }, + ...props +}: SidebarProps) { + const { isMobile, state, openMobile, setOpenMobile } = useSidebar(); + + if (collapsible === 'none') { + return ( + +
+ {children} +
+
+ ); + } + + if (isMobile) { + return ( + + + + Sidebar + Displays the mobile sidebar. + + +
{children}
+
+
+
+ ); + } + + return ( +
+ {/* This is what handles the sidebar gap on desktop */} +
+ +
+ ); +} + +type SidebarTriggerProps = React.ComponentProps; + +function SidebarTrigger({ className, onClick, ...props }: SidebarTriggerProps) { + const { toggleSidebar } = useSidebar(); + + return ( + + ); +} + +type SidebarRailProps = React.ComponentProps<'button'>; + +function SidebarRail({ className, ...props }: SidebarRailProps) { + const { toggleSidebar } = useSidebar(); + + return ( +