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
andPlayWithPet
– 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
- User taps Feed →
FeedPet
dispatched → mood set to happy → saved to disk. - Time passes →
TimeTick
dispatched → mood shifts to hungry/sad. - User renames pet →
SetPetName
updates state and persists it. - 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.