recipe_card_stack 0.0.5 copy "recipe_card_stack: ^0.0.5" to clipboard
recipe_card_stack: ^0.0.5 copied to clipboard

A smooth, swipeable stacked-card widget with tap-to-focus, infinite looping and customizable card layouts.

example/lib/main.dart

import 'package:flutter/material.dart';
import 'package:recipe_card_stack/recipe_card_stack.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      debugShowCheckedModeBanner: false,
      home: Scaffold(
        backgroundColor: Color(0xFFE0E0E0),
        body: Center(
          child: SizedBox(width: 320, height: 480, child: RecipeStackDemo()),
        ),
      ),
    );
  }
}

class RecipeStackDemo extends StatelessWidget {
  const RecipeStackDemo({super.key});

  @override
  Widget build(BuildContext context) {
    final recipes = <DemoRecipe>[
      const DemoRecipe(
        title: 'Banana Cake',
        description: 'Soft, moist banana cake with vanilla.',
        serves: '8',
        prepTime: '15 min',
        cookTime: '35 min',
        color: Color(0xFFFFF7E6),
      ),
      const DemoRecipe(
        title: 'Chocolate Cake',
        description: 'Rich and fudgy chocolate layers.',
        serves: '10',
        prepTime: '20 min',
        cookTime: '30 min',
        color: Color(0xFFFFEFEF),
      ),
      const DemoRecipe(
        title: 'Vanilla Cupcakes',
        description: 'Light vanilla cupcakes with buttercream.',
        serves: '12',
        prepTime: '15 min',
        cookTime: '18 min',
        color: Color(0xFFFFF9F0),
      ),
      const DemoRecipe(
        title: 'Carrot Cake',
        description: 'Spiced carrot cake with cream cheese frosting.',
        serves: '8',
        prepTime: '25 min',
        cookTime: '40 min',
        color: Color(0xFFF7FFF2),
      ),
    ];

    return InfiniteSwipeCardStack<DemoRecipe>(
      items: recipes,
      maxVisibleCards: 4,
      scaleGap: 0.05,
      verticalGap: 70.0,
      cardBuilder: (context, recipe, index, isFrontCard) {
        return DemoRecipeCard(
          key: ValueKey(recipe.title), // use a real id if you have one
          recipe: recipe,
          isFrontCard: isFrontCard,
        );
      },
      onSwipe: (index, direction, recipe) {
        debugPrint('Swiped ${recipe.title} to $direction');
      },
    );
  }
}

class DemoRecipe {
  final String title;
  final String description;
  final String serves;
  final String prepTime;
  final String cookTime;
  final Color color;

  const DemoRecipe({
    required this.title,
    required this.description,
    required this.serves,
    required this.prepTime,
    required this.cookTime,
    required this.color,
  });
}

class DemoRecipeCard extends StatelessWidget {
  final DemoRecipe recipe;
  final bool isFrontCard;

  const DemoRecipeCard({
    super.key,
    required this.recipe,
    required this.isFrontCard,
  });

  @override
  Widget build(BuildContext context) {
    return Container(
      height: 360,
      decoration: BoxDecoration(
        color: recipe.color,
        borderRadius: BorderRadius.circular(8),
        border: Border.all(color: Colors.black, width: 2),
      ),
      child: Stack(
        children: [
          // Back "tab" title strip (always present, stable)
          Align(
            alignment: Alignment.bottomLeft,
            child: Container(
              width: double.infinity,
              color: Colors.black.withOpacity(0.25),
              padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
              child: Text(
                recipe.title,
                style: const TextStyle(
                  fontSize: 16,
                  color: Colors.white,
                  fontWeight: FontWeight.bold,
                ),
                maxLines: 1,
                overflow: TextOverflow.ellipsis,
              ),
            ),
          ),

          // Front details: REAL fade-in on focus change
          FocusFade(
            isFocused: isFrontCard,
            fadeIn: const Duration(milliseconds: 260),
            fadeOut: const Duration(milliseconds: 120),
            curveIn: Curves.easeOutCubic,
            curveOut: Curves.easeOut,
            // Optional: tiny delay so it starts after the swipe settles a bit
            // (feels more premium, less "UI fighting")
            delayOnFocus: const Duration(milliseconds: 60),
            child: _FrontDetails(recipe: recipe),
          ),
        ],
      ),
    );
  }
}

class _FrontDetails extends StatelessWidget {
  final DemoRecipe recipe;

  const _FrontDetails({required this.recipe});

  @override
  Widget build(BuildContext context) {
    return IgnorePointer(
      // Only interactable when focused (FocusFade already handles)
      ignoring: false,
      child: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Center(
              child: Text(
                recipe.title,
                style: const TextStyle(
                  fontSize: 24,
                  fontWeight: FontWeight.bold,
                ),
                textAlign: TextAlign.center,
              ),
            ),
            const SizedBox(height: 16),
            Text(recipe.description, style: const TextStyle(fontSize: 16)),
            const SizedBox(height: 16),
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: [
                Text(
                  "Serves: ${recipe.serves}",
                  style: const TextStyle(fontSize: 16),
                ),
                Text(
                  "Prep: ${recipe.prepTime}",
                  style: const TextStyle(fontSize: 16),
                ),
                Text(
                  "Cook: ${recipe.cookTime}",
                  style: const TextStyle(fontSize: 16),
                ),
              ],
            ),
            const Spacer(),
            Align(
              alignment: Alignment.bottomRight,
              child: Text(
                "Read Recipe",
                style: TextStyle(
                  fontSize: 16,
                  fontWeight: FontWeight.w500,
                  decoration: TextDecoration.underline,
                  color: Colors.black.withOpacity(0.8),
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

/// A fade wrapper that *actually animates* when focus changes.
/// This avoids the common "it popped in" issue when widgets rebuild
/// already in the focused state.
class FocusFade extends StatefulWidget {
  const FocusFade({
    super.key,
    required this.isFocused,
    required this.child,
    this.fadeIn = const Duration(milliseconds: 240),
    this.fadeOut = const Duration(milliseconds: 140),
    this.curveIn = Curves.easeOut,
    this.curveOut = Curves.easeOut,
    this.delayOnFocus = Duration.zero,
  });

  final bool isFocused;
  final Widget child;

  final Duration fadeIn;
  final Duration fadeOut;
  final Curve curveIn;
  final Curve curveOut;

  /// Optional delay before starting fade in (helps after swipe)
  final Duration delayOnFocus;

  @override
  State<FocusFade> createState() => _FocusFadeState();
}

class _FocusFadeState extends State<FocusFade>
    with SingleTickerProviderStateMixin {
  late final AnimationController _controller;
  late Animation<double> _opacity;

  @override
  void initState() {
    super.initState();

    _controller = AnimationController(
      vsync: this,
      duration: widget.fadeIn,
      reverseDuration: widget.fadeOut,
      value: widget.isFocused ? 1.0 : 0.0,
    );

    _opacity = CurvedAnimation(
      parent: _controller,
      curve: widget.curveIn,
      reverseCurve: widget.curveOut,
    );
  }

  @override
  void didUpdateWidget(covariant FocusFade oldWidget) {
    super.didUpdateWidget(oldWidget);

    // If timing/curves change, refresh controller settings
    if (oldWidget.fadeIn != widget.fadeIn) _controller.duration = widget.fadeIn;
    if (oldWidget.fadeOut != widget.fadeOut) {
      _controller.reverseDuration = widget.fadeOut;
    }

    final becameFocused = !oldWidget.isFocused && widget.isFocused;
    final becameUnfocused = oldWidget.isFocused && !widget.isFocused;

    if (becameFocused) {
      if (widget.delayOnFocus == Duration.zero) {
        _controller.forward();
      } else {
        Future.delayed(widget.delayOnFocus, () {
          if (!mounted) return;
          // only fade in if still focused
          if (widget.isFocused) _controller.forward();
        });
      }
    } else if (becameUnfocused) {
      _controller.reverse();
    }
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return IgnorePointer(
      ignoring: !widget.isFocused,
      child: FadeTransition(opacity: _opacity, child: widget.child),
    );
  }
}
2
likes
140
points
172
downloads

Publisher

unverified uploader

Weekly Downloads

A smooth, swipeable stacked-card widget with tap-to-focus, infinite looping and customizable card layouts.

Repository (GitHub)
View/report issues

Topics

#cards #swipe #stack #ui #selector

Documentation

API reference

License

MIT (license)

Dependencies

flutter

More

Packages that depend on recipe_card_stack