Salta ai contenuti

Flutter Timer

beginner

In questo tutorial vedremo come costruire un timer usando la libreria bloc. L'applicazione risultante dovrebbe essere così:

demo

  • Osservare i cambiamenti di stato con BlocObserver;
  • BlocProvider, widget Flutter che fornisce un bloc ai suoi figli;
  • BlocBuilder, widget Flutter che gestisce la costruzione del widget in risposta a nuovi stati;
  • Prevenire aggiornamenti non necessari con Equatable;
  • Imparare a usare StreamSubscription in un Bloc;
  • Prevenire aggiornamenti non necessari con buildWhen.

Inizieremo creando un nuovo progetto Flutter:

Terminal window
flutter create flutter_timer

Possiamo poi sostituire il contenuto di pubspec.yaml con:

pubspec.yaml
name: flutter_timer
description: A new Flutter project.
version: 1.0.0+1
publish_to: none
environment:
sdk: ^3.12.0
dependencies:
bloc: ^9.0.0
equatable: ^2.1.0-dev.0
flutter:
sdk: flutter
flutter_bloc: ^9.1.0
dev_dependencies:
bloc_lint: ^0.3.0
bloc_test: ^10.0.0
flutter_test:
sdk: flutter
mocktail: ^1.0.0
flutter:
uses-material-design: true

Successivamente, esegui flutter pub get per installare tutte le dipendenze.

├── lib
| ├── timer
│ │ ├── bloc
│ │ │ └── timer_bloc.dart
| | | └── timer_event.dart
| | | └── timer_state.dart
│ │ └── view
│ │ | ├── timer_page.dart
│ │ ├── timer.dart
│ ├── app.dart
│ ├── ticker.dart
│ └── main.dart
├── pubspec.lock
├── pubspec.yaml

Il ticker sarà la sorgente di dati per l'applicazione timer. Esporrà uno stream di "tick" a cui possiamo sottoscriverci e reagire.

Inizia creando ticker.dart.

lib/ticker.dart
class Ticker {
const Ticker();
Stream<int> tick({required int ticks}) {
return Stream.periodic(
const Duration(seconds: 1),
(x) => ticks - x - 1,
).take(ticks);
}
}

Tutto ciò che fa la classe Ticker è esporre una funzione tick che prende il numero di tick (secondi) che vogliamo e restituisce uno stream che emette i secondi rimanenti ogni secondo.

Successivamente, dobbiamo creare il TimerBloc che consumerà il Ticker.

Inizieremo definendo i TimerStates in cui il TimerBloc può trovarsi.

Lo stato del TimerBloc può essere uno dei seguenti:

  • TimerInitial: pronto per iniziare il conteggio alla rovescia dalla durata specificata.
  • TimerRunInProgress: sta attivamente contando alla rovescia dalla durata specificata.
  • TimerRunPause: in pausa a una durata rimanente.
  • TimerRunComplete: completato con una durata rimanente di 0.

Ognuno di questi stati avrà un'implicazione sull'interfaccia utente e sulle azioni che l'utente può eseguire. Ad esempio:

  • se lo stato è TimerInitial l'utente sarà in grado di avviare il timer;
  • se lo stato è TimerRunInProgress l'utente sarà in grado di mettere in pausa e resettare il timer così come vedere la durata rimanente;
  • se lo stato è TimerRunPause l'utente sarà in grado di riprendere il timer e resettare il timer;
  • se lo stato è TimerRunComplete l'utente sarà in grado di resettare il timer.

Per mantenere tutti i nostri file bloc insieme, creiamo una directory bloc con bloc/timer_state.dart.

lib/timer/bloc/timer_state.dart
part of 'timer_bloc.dart';
sealed class TimerState extends Equatable {
const TimerState(this.duration);
final int duration;
@override
List<Object> get props => [duration];
}
final class TimerInitial extends TimerState {
const TimerInitial(super.duration);
@override
String toString() => 'TimerInitial { duration: $duration }';
}
final class TimerRunPause extends TimerState {
const TimerRunPause(super.duration);
@override
String toString() => 'TimerRunPause { duration: $duration }';
}
final class TimerRunInProgress extends TimerState {
const TimerRunInProgress(super.duration);
@override
String toString() => 'TimerRunInProgress { duration: $duration }';
}
final class TimerRunComplete extends TimerState {
const TimerRunComplete() : super(0);
}

Nota che tutti i TimerStates estendono la classe base astratta TimerState che ha una proprietà duration. Questo è perché non importa in quale stato il nostro TimerBloc sia, vogliamo sempre sapere quanto tempo rimane. Inoltre, TimerState estende Equatable per ottimizzare il nostro codice assicurandoci che la nostra app non attivi aggiornamenti se viene emesso uno stato uguale al precedente.

Successivamente, definiamo e implementiamo i TimerEvents che il nostro TimerBloc processerà.

Il nostro TimerBloc dovrà sapere come processare i seguenti eventi:

  • TimerStarted: informa il TimerBloc che il timer dovrebbe essere avviato;
  • TimerPaused: informa il TimerBloc che il timer dovrebbe essere messo in pausa;
  • TimerResumed: informa il TimerBloc che il timer dovrebbe essere ripreso;
  • TimerReset: informa il TimerBloc che il timer dovrebbe essere resettato allo stato originale;
  • _TimerTicked: informa il TimerBloc che si è verificato un tick e che deve aggiornare il suo stato di conseguenza.

Se non hai usato le IntelliJ o VSCode estensioni, crea bloc/timer_event.dart e implementiamo quegli eventi.

lib/timer/bloc/timer_event.dart
part of 'timer_bloc.dart';
sealed class TimerEvent {
const TimerEvent();
}
final class TimerStarted extends TimerEvent {
const TimerStarted({required this.duration});
final int duration;
}
final class TimerPaused extends TimerEvent {
const TimerPaused();
}
final class TimerResumed extends TimerEvent {
const TimerResumed();
}
class TimerReset extends TimerEvent {
const TimerReset();
}
class _TimerTicked extends TimerEvent {
const _TimerTicked({required this.duration});
final int duration;
}

Successivamente, implementiamo il TimerBloc!

Se non l'hai già fatto, crea bloc/timer_bloc.dart e crea un TimerBloc vuoto.

lib/timer/bloc/timer_bloc.dart
import 'package:bloc/bloc.dart';
part 'timer_event.dart';
part 'timer_state.dart';
class TimerBloc extends Bloc<TimerEvent, TimerState> {
// TODO: set initial state
TimerBloc(): super() {
// TODO: implement event handlers
}
}

La prima cosa che dobbiamo fare è definire lo stato iniziale del nostro TimerBloc. In questo caso, vogliamo che il TimerBloc inizi nello stato TimerInitial con una durata preimpostata di 1 minuto (60 secondi).

lib/timer/bloc/timer_bloc.dart
import 'package:bloc/bloc.dart';
part 'timer_event.dart';
part 'timer_state.dart';
class TimerBloc extends Bloc<TimerEvent, TimerState> {
static const int _duration = 60;
TimerBloc() : super(TimerInitial(_duration)) {
// TODO: implement event handlers
}
}

Successivamente, dobbiamo definire la dipendenza sul nostro Ticker.

lib/timer/bloc/timer_bloc.dart
import 'dart:async';
import 'package:bloc/bloc.dart';
import 'package:flutter_timer/ticker.dart';
part 'timer_event.dart';
part 'timer_state.dart';
class TimerBloc extends Bloc<TimerEvent, TimerState> {
final Ticker _ticker;
static const int _duration = 60;
StreamSubscription<int>? _tickerSubscription;
TimerBloc({required Ticker ticker})
: _ticker = ticker,
super(TimerInitial(_duration)) {
// TODO: implement event handlers
}
}

Stiamo anche definendo una StreamSubscription per il nostro Ticker che implementeremo tra poco.

A questo punto, tutto ciò che resta da fare è implementare i gestori di eventi. Per migliorare la leggibilità, mi piace separare ogni gestore di eventi in una funzione dedicata. Inizieremo con l'evento TimerStarted.

lib/timer/bloc/timer_bloc.dart
import 'dart:async';
import 'package:bloc/bloc.dart';
import 'package:flutter_timer/ticker.dart';
part 'timer_event.dart';
part 'timer_state.dart';
class TimerBloc extends Bloc<TimerEvent, TimerState> {
final Ticker _ticker;
static const int _duration = 60;
StreamSubscription<int>? _tickerSubscription;
TimerBloc({required Ticker ticker})
: _ticker = ticker,
super(TimerInitial(_duration)) {
on<TimerStarted>(_onStarted);
}
@override
Future<void> close() {
_tickerSubscription?.cancel();
return super.close();
}
void _onStarted(TimerStarted event, Emitter<TimerState> emit) {
emit(TimerRunInProgress(event.duration));
_tickerSubscription?.cancel();
_tickerSubscription = _ticker
.tick(ticks: event.duration)
.listen((duration) => add(_TimerTicked(duration: duration)));
}
}

Se il TimerBloc riceve un evento TimerStarted, emette uno stato TimerRunInProgress con la durata iniziale. Inoltre, se c'era già una _tickerSubscription aperta dobbiamo cancellarla per deallocare la memoria. Dobbiamo anche sovrascrivere il metodo close sul nostro TimerBloc così che possiamo cancellare la _tickerSubscription quando il TimerBloc è chiuso. Infine, ascoltiamo lo stream _ticker.tick e su ogni tick aggiungiamo un evento _TimerTicked con la durata rimanente.

Successivamente, implementiamo il gestore di eventi _TimerTicked.

lib/timer/bloc/timer_bloc.dart
import 'dart:async';
import 'package:bloc/bloc.dart';
import 'package:flutter_timer/ticker.dart';
part 'timer_event.dart';
part 'timer_state.dart';
class TimerBloc extends Bloc<TimerEvent, TimerState> {
final Ticker _ticker;
static const int _duration = 60;
StreamSubscription<int>? _tickerSubscription;
TimerBloc({required Ticker ticker})
: _ticker = ticker,
super(TimerInitial(_duration)) {
on<TimerStarted>(_onStarted);
on<_TimerTicked>(_onTicked);
}
@override
Future<void> close() {
_tickerSubscription?.cancel();
return super.close();
}
void _onStarted(TimerStarted event, Emitter<TimerState> emit) {
emit(TimerRunInProgress(event.duration));
_tickerSubscription?.cancel();
_tickerSubscription = _ticker
.tick(ticks: event.duration)
.listen((duration) => add(_TimerTicked(duration: duration)));
}
void _onTicked(_TimerTicked event, Emitter<TimerState> emit) {
emit(
event.duration > 0
? TimerRunInProgress(event.duration)
: TimerRunComplete(),
);
}
}

Ogni volta che viene ricevuto un evento _TimerTicked, se la durata del tick è maggiore di 0, dobbiamo emettere uno stato TimerRunInProgress aggiornato con la nuova durata. Altrimenti, se la durata del tick è 0, il nostro timer è terminato e dobbiamo emettere uno stato TimerRunComplete.

Ora implementiamo il gestore di eventi TimerPaused.

lib/timer/bloc/timer_bloc.dart
import 'dart:async';
import 'package:bloc/bloc.dart';
import 'package:flutter_timer/ticker.dart';
part 'timer_event.dart';
part 'timer_state.dart';
class TimerBloc extends Bloc<TimerEvent, TimerState> {
final Ticker _ticker;
static const int _duration = 60;
StreamSubscription<int>? _tickerSubscription;
TimerBloc({required Ticker ticker})
: _ticker = ticker,
super(TimerInitial(_duration)) {
on<TimerStarted>(_onStarted);
on<TimerPaused>(_onPaused);
on<_TimerTicked>(_onTicked);
}
@override
Future<void> close() {
_tickerSubscription?.cancel();
return super.close();
}
void _onStarted(TimerStarted event, Emitter<TimerState> emit) {
emit(TimerRunInProgress(event.duration));
_tickerSubscription?.cancel();
_tickerSubscription = _ticker
.tick(ticks: event.duration)
.listen((duration) => add(_TimerTicked(duration: duration)));
}
void _onPaused(TimerPaused event, Emitter<TimerState> emit) {
if (state is TimerRunInProgress) {
_tickerSubscription?.pause();
emit(TimerRunPause(state.duration));
}
}
void _onTicked(_TimerTicked event, Emitter<TimerState> emit) {
emit(
event.duration > 0
? TimerRunInProgress(event.duration)
: TimerRunComplete(),
);
}
}

In _onPaused se lo state del nostro TimerBloc è TimerRunInProgress, allora possiamo mettere in pausa la _tickerSubscription ed emettere uno stato TimerRunPause con la durata del timer corrente.

Successivamente, implementiamo il gestore di eventi TimerResumed così possiamo riprendere il timer.

lib/timer/bloc/timer_bloc.dart
import 'dart:async';
import 'package:bloc/bloc.dart';
import 'package:flutter_timer/ticker.dart';
part 'timer_event.dart';
part 'timer_state.dart';
class TimerBloc extends Bloc<TimerEvent, TimerState> {
final Ticker _ticker;
static const int _duration = 60;
StreamSubscription<int>? _tickerSubscription;
TimerBloc({required Ticker ticker})
: _ticker = ticker,
super(TimerInitial(_duration)) {
on<TimerStarted>(_onStarted);
on<TimerPaused>(_onPaused);
on<TimerResumed>(_onResumed);
on<_TimerTicked>(_onTicked);
}
@override
Future<void> close() {
_tickerSubscription?.cancel();
return super.close();
}
void _onStarted(TimerStarted event, Emitter<TimerState> emit) {
emit(TimerRunInProgress(event.duration));
_tickerSubscription?.cancel();
_tickerSubscription = _ticker
.tick(ticks: event.duration)
.listen((duration) => add(_TimerTicked(duration: duration)));
}
void _onPaused(TimerPaused event, Emitter<TimerState> emit) {
if (state is TimerRunInProgress) {
_tickerSubscription?.pause();
emit(TimerRunPause(state.duration));
}
}
void _onResumed(TimerResumed resume, Emitter<TimerState> emit) {
if (state is TimerRunPause) {
_tickerSubscription?.resume();
emit(TimerRunInProgress(state.duration));
}
}
void _onTicked(_TimerTicked event, Emitter<TimerState> emit) {
emit(
event.duration > 0
? TimerRunInProgress(event.duration)
: TimerRunComplete(),
);
}
}

Il gestore di eventi TimerResumed è molto simile al gestore di eventi TimerPaused. Se il TimerBloc ha uno state di TimerRunPause e riceve un evento TimerResumed, riprende la _tickerSubscription ed emette uno stato TimerRunInProgress con la durata corrente.

Infine, dobbiamo implementare il gestore di eventi TimerReset.

lib/timer/bloc/timer_bloc.dart
import 'dart:async';
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter_timer/ticker.dart';
part 'timer_event.dart';
part 'timer_state.dart';
class TimerBloc extends Bloc<TimerEvent, TimerState> {
TimerBloc({required this._ticker}) : super(const TimerInitial(_duration)) {
on<TimerStarted>(_onStarted);
on<TimerPaused>(_onPaused);
on<TimerResumed>(_onResumed);
on<TimerReset>(_onReset);
on<_TimerTicked>(_onTicked);
}
final Ticker _ticker;
static const int _duration = 60;
StreamSubscription<int>? _tickerSubscription;
@override
Future<void> close() {
_tickerSubscription?.cancel();
return super.close();
}
void _onStarted(TimerStarted event, Emitter<TimerState> emit) {
emit(TimerRunInProgress(event.duration));
_tickerSubscription?.cancel();
_tickerSubscription = _ticker
.tick(ticks: event.duration)
.listen((duration) => add(_TimerTicked(duration: duration)));
}
void _onPaused(TimerPaused event, Emitter<TimerState> emit) {
if (state is TimerRunInProgress) {
_tickerSubscription?.pause();
emit(TimerRunPause(state.duration));
}
}
void _onResumed(TimerResumed resume, Emitter<TimerState> emit) {
if (state is TimerRunPause) {
_tickerSubscription?.resume();
emit(TimerRunInProgress(state.duration));
}
}
void _onReset(TimerReset event, Emitter<TimerState> emit) {
_tickerSubscription?.cancel();
emit(const TimerInitial(_duration));
}
void _onTicked(_TimerTicked event, Emitter<TimerState> emit) {
emit(
event.duration > 0
? TimerRunInProgress(event.duration)
: const TimerRunComplete(),
);
}
}

Se il TimerBloc riceve un evento TimerReset, deve cancellare la corrente _tickerSubscription così che non viene notificato di tick aggiuntivi ed emette uno stato TimerInitial con la durata originale.

Questo è tutto per il TimerBloc. Ora tutto ciò che resta è implementare l'UI per il nostro Timer.

Possiamo iniziare cancellando il contenuto di main.dart e sostituendolo con il seguente.

lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_timer/app.dart';
void main() => runApp(const App());

Successivamente, creiamo il nostro widget App in app.dart, che sarà la radice della nostra applicazione.

lib/app.dart
import 'package:flutter/material.dart';
import 'package:flutter_timer/timer/timer.dart';
class App extends StatelessWidget {
const App({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Timer',
theme: ThemeData(
colorScheme: const ColorScheme.light(
primary: Color.fromRGBO(72, 74, 126, 1),
),
),
home: const TimerPage(),
);
}
}

Successivamente, dobbiamo implementare il nostro widget Timer.

Il nostro widget Timer (lib/timer/view/timer_page.dart) sarà responsabile di visualizzare il tempo rimanente insieme ai pulsanti appropriati che permetteranno agli utenti di avviare, mettere in pausa e resettare il timer.

lib/timer/view/timer_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_timer/ticker.dart';
import 'package:flutter_timer/timer/timer.dart';
class TimerPage extends StatelessWidget {
const TimerPage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => TimerBloc(ticker: Ticker()),
child: const TimerView(),
);
}
}
class TimerView extends StatelessWidget {
const TimerView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Flutter Timer')),
body: Stack(
children: [
const Background(),
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: const <Widget>[
Padding(
padding: EdgeInsets.symmetric(vertical: 100.0),
child: Center(child: TimerText()),
),
Actions(),
],
),
],
),
);
}
}
class TimerText extends StatelessWidget {
const TimerText({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final duration = context.select((TimerBloc bloc) => bloc.state.duration);
final minutesStr =
((duration / 60) % 60).floor().toString().padLeft(2, '0');
final secondsStr = (duration % 60).floor().toString().padLeft(2, '0');
return Text(
'$minutesStr:$secondsStr',
style: Theme.of(
context,
).textTheme.displayLarge?.copyWith(fontWeight: FontWeight.w500),
);
}
}

Finora, stiamo solo usando BlocProvider per accedere all'istanza del nostro TimerBloc.

Successivamente, implementeremo il nostro widget Actions che avrà le azioni appropriate (avvia, pausa e reset).

Per pulire i nostri import dalla sezione Timer, dobbiamo creare un file "barrel" timer/timer.dart.

lib/timer/timer.dart
export 'bloc/timer_bloc.dart';
export 'view/timer_page.dart';
lib/timer/view/timer_page.dart
class Actions extends StatelessWidget {
const Actions({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<TimerBloc, TimerState>(
buildWhen: (prev, state) => prev.runtimeType != state.runtimeType,
builder: (context, state) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
...switch (state) {
TimerInitial() => [
FloatingActionButton(
child: const Icon(Icons.play_arrow),
onPressed: () => context
.read<TimerBloc>()
.add(TimerStarted(duration: state.duration)),
),
],
TimerRunInProgress() => [
FloatingActionButton(
child: const Icon(Icons.pause),
onPressed: () =>
context.read<TimerBloc>().add(const TimerPaused()),
),
FloatingActionButton(
child: const Icon(Icons.replay),
onPressed: () =>
context.read<TimerBloc>().add(const TimerReset()),
),
],
TimerRunPause() => [
FloatingActionButton(
child: const Icon(Icons.play_arrow),
onPressed: () =>
context.read<TimerBloc>().add(const TimerResumed()),
),
FloatingActionButton(
child: const Icon(Icons.replay),
onPressed: () =>
context.read<TimerBloc>().add(const TimerReset()),
),
],
TimerRunComplete() => [
FloatingActionButton(
child: const Icon(Icons.replay),
onPressed: () =>
context.read<TimerBloc>().add(const TimerReset()),
),
]
}
],
);
},
);
}
}

Il widget Actions è solo un altro StatelessWidget che usa un BlocBuilder per ricostruire la UI ogni volta che otteniamo un nuovo TimerState. Actions usa context.read<TimerBloc>() per accedere all'istanza del TimerBloc e restituisce diversi FloatingActionButtons in base allo stato corrente del TimerBloc. Ognuno dei FloatingActionButtons aggiunge un evento nel suo callback onPressed per notificare il TimerBloc.

Se vuoi un controllo granulare su quando la funzione builder viene chiamata puoi fornire opzionalmente un buildWhen a BlocBuilder. Il buildWhen prende lo stato precedente del bloc e lo stato corrente del bloc e restituisce un booleano. Se buildWhen restituisce true, builder sarà chiamato con state e il widget si ricostruirà. Se buildWhen restituisce false, builder non sarà chiamato con state e non si verificherà alcun aggiornamento.

In questo caso, non vogliamo che il widget Actions sia ricostruito su ogni tick perché sarebbe inefficiente. Invece, vogliamo che Actions si ricostruisca solo se il runtimeType del TimerState cambia (TimerInitial => TimerRunInProgress, TimerRunInProgress => TimerRunPause, ecc...).

Di conseguenza, se colorassimo casualmente i widget su ogngli aggiornamenti, sarebbe:

BlocBuilder buildWhen demo

Infine, aggiungi il widget background come segue:

lib/timer/view/timer_page.dart
class Background extends StatelessWidget {
const Background({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.blue.shade50,
Colors.blue.shade500,
],
),
),
);
}
}

Questo è tutto! A questo punto abbiamo un'applicazione timer abbastanza solida che ricostruisce efficientemente i widget solo quando necessario.

Il codice sorgente completo per questo esempio può essere trovato qui.