Here’s How I Built a Virtual Pet App in Flutter

Building a reliable app in Flutter starts with managing state. What better way to practice than by building a cute, virtual pet?

In this post, we’ll build a simple virtual cat companion that eats, plays, and remembers you. Under the hood, it’s really a lesson in state management: how to represent state, update it over time, and persist it across sessions. Using Bloc and Shared Preferences, we’ll see how even a playful project can teach the foundations of reliable app architecture.

State Management with Bloc

Bloc separates business logic from UI, making it easier to test and maintain. In this app, Bloc manages the pet’s mood, last interactions, and name, while the UI simply reacts to those changes.

Defining Events and State

We track three moods for our pet, plus events that can change its state:

  • FeedPet and PlayWithPet – triggered by button presses.
  • SetPetName – triggered when the user renames the pet.
  • TimeTick – dispatched periodically to let the pet’s mood decay naturally.
  • LoadPetState – loads data from storage when the app starts.

enum PetMood { happy, hungry, sad }

class PetState {
  final PetMood mood;
  final int lastFed;
  final int lastPlayed;
  final String name;
  final bool isLoaded;

  PetState({
    required this.mood,
    required this.lastFed,
    required this.lastPlayed,
    required this.name,
    this.isLoaded = false,
  });

  PetState copyWith({
    PetMood? mood,
    int? lastFed,
    int? lastPlayed,
    String? name,
    bool? isLoaded,
  }) {
    return PetState(
      mood: mood ?? this.mood,
      lastFed: lastFed ?? this.lastFed,
      lastPlayed: lastPlayed ?? this.lastPlayed,
      name: name ?? this.name,
      isLoaded: isLoaded ?? this.isLoaded,
    );
  }
}

abstract class PetEvent {}

class FeedPet extends PetEvent {}
class PlayWithPet extends PetEvent {}
class TimeTick extends PetEvent {}
class SetPetName extends PetEvent {
  final String name;
  SetPetName(this.name);
}
class LoadPetState extends PetEvent {}

How Bloc Updates State

  1. User taps FeedFeedPet dispatched → mood set to happy → saved to disk.
  2. Time passes → TimeTick dispatched → mood shifts to hungry/sad.
  3. User renames pet → SetPetName updates state and persists it.
  4. App starts → LoadPetState restores last saved data.
class PetBloc extends Bloc<PetEvent, PetState> {
  Timer? _timer;
  SharedPreferences? _prefs;

  PetBloc()
      : super(PetState(
          mood: PetMood.happy,
          lastFed: DateTime.now().millisecondsSinceEpoch,
          lastPlayed: DateTime.now().millisecondsSinceEpoch,
          name: "Cat",
        )) {
    on<LoadPetState>(_onLoadPetState);
    on<FeedPet>(_onFeedPet);
    on<PlayWithPet>(_onPlayWithPet);
    on<SetPetName>(_onSetPetName);
    on<TimeTick>(_onTimeTick);
  }

  Future<void> initBloc() async {
    _prefs = await SharedPreferences.getInstance();
    add(LoadPetState());
    _timer = Timer.periodic(const Duration(seconds: 5), (_) => add(TimeTick()));
  }

  Future<void> _onLoadPetState(
      LoadPetState event, Emitter<PetState> emit) async {
    final lastFed =
        _prefs?.getInt('lastFed') ?? DateTime.now().millisecondsSinceEpoch;
    final lastPlayed =
        _prefs?.getInt('lastPlayed') ?? DateTime.now().millisecondsSinceEpoch;
    final name = _prefs?.getString('petName') ?? "Cat";
    emit(state.copyWith(
        lastFed: lastFed, lastPlayed: lastPlayed, name: name, isLoaded: true));
  }

  Future<void> _onFeedPet(FeedPet event, Emitter<PetState> emit) async {
    final newState = state.copyWith(
        mood: PetMood.happy, lastFed: DateTime.now().millisecondsSinceEpoch);
    emit(newState);
    await _saveState(newState);
  }

  Future<void> _onPlayWithPet(PlayWithPet event, Emitter<PetState> emit) async {
    final newState = state.copyWith(
        mood: PetMood.happy, lastPlayed: DateTime.now().millisecondsSinceEpoch);
    emit(newState);
    await _saveState(newState);
  }

  Future<void> _onSetPetName(SetPetName event, Emitter<PetState> emit) async {
    final newState = state.copyWith(name: event.name);
    emit(newState);
    await _saveState(newState);
  }

  Future<void> _onTimeTick(TimeTick event, Emitter<PetState> emit) async {
    final now = DateTime.now().millisecondsSinceEpoch;
    final fedAgo = now - state.lastFed;
    final playedAgo = now - state.lastPlayed;
    PetMood mood = state.mood;
    if (fedAgo > 40000) {
      mood = PetMood.hungry;
    } else if (playedAgo > 30000) {
      mood = PetMood.sad;
    } else {
      mood = PetMood.happy;
    }
    final newState = state.copyWith(mood: mood);
    emit(newState);
    await _saveState(newState);
  }

  Future<void> _saveState(PetState state) async {
    await _prefs?.setInt('lastFed', state.lastFed);
    await _prefs?.setInt('lastPlayed', state.lastPlayed);
    await _prefs?.setString('petName', state.name);
  }

  @override
  Future<void> close() {
    _timer?.cancel();
    return super.close();
  }
}

Persisting State with Shared Preferences

To keep the pet’s mood and name across app restarts, we use the shared_preferences package. It lets us store simple key-value pairs on the device.

Whenever the pet’s state changes, we save it:

Future<void> _saveState(PetState state) async {
  await _prefs?.setInt('lastFed', state.lastFed);
  await _prefs?.setInt('lastPlayed', state.lastPlayed);
  await _prefs?.setString('petName', state.name);
}

And when the app starts, we load the saved state:

Future<void> _onLoadPetState(
    LoadPetState event, Emitter<PetState> emit) async {
  final lastFed =
      _prefs?.getInt('lastFed') ?? DateTime.now().millisecondsSinceEpoch;
  final lastPlayed =
      _prefs?.getInt('lastPlayed') ?? DateTime.now().millisecondsSinceEpoch;
  final name = _prefs?.getString('petName') ?? "Cat";
  emit(state.copyWith(
      lastFed: lastFed, lastPlayed: lastPlayed, name: name, isLoaded: true));
}

This keeps the app lightweight but gives our pet a “memory.” When you reopen the app, your cat still remembers its name and mood.

Bonus: Adding a Fun Animation

So far, our pet has moods and memory. Let’s make it feel alive. A little bounce animation when the user interacts with the pet creates playful feedback and makes the app feel more dynamic.

How the Bounce Works

// In _PetHomePageState
_controller = AnimationController(
  duration: const Duration(milliseconds: 900),
  vsync: this,
);
_bounceAnimation = TweenSequence<double>([
  TweenSequenceItem(
    tween: Tween<double>(begin: 0, end: -12)
        .chain(CurveTween(curve: Curves.easeOutBack)),
    weight: 60,
  ),
  TweenSequenceItem(
    tween: Tween<double>(begin: -12, end: 0)
        .chain(CurveTween(curve: Curves.easeIn)),
    weight: 40,
  ),
]).animate(_controller);

void _animatePet() {
  _controller.forward(from: 0);
}

Applied to the pet image:

AnimatedBuilder(
  animation: _bounceAnimation,
  builder: (context, child) {
    return Transform.translate(
      offset: Offset(0, _bounceAnimation.value),
      child: child,
    );
  },
  child: CircleAvatar(
    radius: 70,
    backgroundImage: AssetImage(_catAsset(state.mood)),
    backgroundColor: Colors.white,
  ),
)


Lessons from a Virtual Cat

With Bloc and Shared Preferences, we didn’t just build a fun app. We explored how to manage time, state, and persistence in a way that feels alive. Our cat companion smiles when fed, sulks when ignored, and happily remembers your care between sessions.

That mix of reactivity and memory is what makes software feel reliable and human-friendly. Whether you’re practicing state management in a playful project or applying it to a client app, the patterns are the same, and they stick better when you’ve seen them through a pair of curious cat eyes.

Conversation

Join the conversation

Your email address will not be published. Required fields are marked *