تخطَّ إلى المحتوى

مؤقت Flutter (Flutter Timer)

beginner

في هذا الدليل، سنتعلّم كيفية بناء تطبيق مؤقّت باستخدام مكتبة Bloc. يفترض أن يبدو التطبيق النهائي بهذا الشكل:

demo

  • مراقبة تغييرات الحالة باستخدام BlocObserver.
  • BlocProvider، وهي Widget في Flutter توفّر Bloc للأبناء.
  • BlocBuilder، وهي Widget في Flutter تتولّى إعادة البناء استجابةً للحالات الجديدة.
  • منع إعادة البناء غير الضرورية باستخدام Equatable.
  • تعلم استخدام StreamSubscription داخل Bloc.
  • منع إعادة البناء غير الضرورية باستخدام buildWhen.

سنبدأ بإنشاء مشروع Flutter جديد بالكامل:

Terminal window
flutter create flutter_timer

بعد ذلك، يمكننا استبدال محتويات pubspec.yaml بما يلي:

pubspec.yaml
name: flutter_timer
description: A new Flutter project.
version: 1.0.0+1
publish_to: none
environment:
sdk: ">=3.10.0 <4.0.0"
dependencies:
bloc: ^9.0.0
equatable: ^2.0.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

ثم شغّل flutter pub get لتثبيت جميع التبعيات (dependencies).

├── 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

سيكون Ticker هو مصدر البيانات لتطبيق المؤقّت. إذ يوفّر stream من النبضات (ticks) يمكننا الاشتراك فيه والتفاعل معه.

ابدأ بإنشاء الملف 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);
}
}

كل ما يفعله class Ticker هو توفير الدالة tick التي تستقبل عدد النبضات (الثواني) المطلوب، ثم ترجع stream يصدر الثواني المتبقية كل ثانية.

بعد ذلك، نحتاج إلى إنشاء TimerBloc الذي سيستهلك Ticker.

سنبدأ بتعريف TimerStates التي يمكن أن تكون عليها TimerBloc.

يمكن أن تكون حالة TimerBloc الخاصة بنا واحدة مما يلي:

  • TimerInitial: جاهز لبدء العد التنازلي من المدة المحددة.
  • TimerRunInProgress: يعد تنازليًا بنشاط من المدة المحددة.
  • TimerRunPause: متوقف مؤقتًا عند مدة متبقية معينة.
  • TimerRunComplete: اكتمل بمدة متبقية 0.

كل حالة من هذه الحالات تؤثر على واجهة المستخدم والإجراءات المتاحة للمستخدم. على سبيل المثال:

  • إذا كانت الحالة هي TimerInitial، فسيتمكن المستخدم من بدء المؤقت.
  • إذا كانت الحالة هي TimerRunInProgress، فسيتمكن المستخدم من إيقاف المؤقت مؤقتًا وإعادة تعيينه، بالإضافة إلى رؤية المدة المتبقية.
  • إذا كانت الحالة هي TimerRunPause، فسيتمكن المستخدم من استئناف المؤقت وإعادة تعيينه.
  • إذا كانت الحالة هي TimerRunComplete، فسيتمكن المستخدم من إعادة تعيين المؤقت.

للحفاظ على جميع ملفات bloc معًا، لننشئ مجلد bloc ونضيف 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);
}

لاحظ أن جميع TimerStates ترث من abstract base class TimerState الذي يحتوي على الخاصية duration. السبب هو أننا نريد معرفة الوقت المتبقي مهما كانت حالة TimerBloc. بالإضافة إلى ذلك، يرث TimerState من Equatable لتحسين الأكواد البرمجية ومنع إعادة البناء عندما تتكرر الحالة نفسها.

بعد ذلك، لنحدّد وننفّذ TimerEvents التي سيعالجها TimerBloc.

يحتاج TimerBloc إلى معرفة كيفية معالجة الأحداث التالية:

  • TimerStarted: يُعلم TimerBloc بضرورة بدء المؤقت.
  • TimerPaused: يُعلم TimerBloc بضرورة إيقاف المؤقت مؤقتًا.
  • TimerResumed: يُعلم TimerBloc بضرورة استئناف المؤقت.
  • TimerReset: يُعلم TimerBloc بضرورة إعادة تعيين المؤقت إلى حالته الأصلية.
  • _TimerTicked: يُعلم TimerBloc بحدوث نبضة (tick) وبضرورة تحديث حالته وفقًا لذلك.

إذا لم تستخدم إضافات IntelliJ أو VSCode، فأنشئ bloc/timer_event.dart ونفّذ هذه الأحداث.

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;
}

والآن لننفّذ TimerBloc.

إذا لم تكن قد فعلت ذلك بعد، فأنشئ bloc/timer_bloc.dart وأنشئ TimerBloc فارغًا.

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
}
}

أول خطوة هي تحديد الحالة الأولية لـ TimerBloc. هنا نريد أن يبدأ TimerBloc في حالة TimerInitial بمدة مضبوطة مسبقًا تبلغ دقيقة واحدة (60 ثانية).

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
}
}

بعد ذلك، نحتاج إلى تحديد dependency على 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
}
}

كما نعرّف StreamSubscription لـ Ticker وسنعود إليها بعد قليل.

في هذه المرحلة، المتبقي هو تنفيذ event handlers. ولتحسين قابلية القراءة، نفصل كل معالج في helper function مستقلة. سنبدأ بحدث 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)));
}
}

إذا استقبل TimerBloc حدث TimerStarted، فإنه يصدر حالة TimerRunInProgress بمدة البداية. وإذا كانت _tickerSubscription مفتوحة مسبقًا، فنحتاج إلى إلغائها لتحرير الذاكرة. كما نحتاج إلى عمل override للدالة close في TimerBloc حتى نلغي _tickerSubscription عند إغلاق الـ bloc. أخيرًا، نستمع إلى stream _ticker.tick، ومع كل نبضة نضيف حدث _TimerTicked بالمدة المتبقية.

بعد ذلك، دعنا ننفذ معالج حدث _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(),
);
}
}

في كل مرة نستقبل فيها حدث _TimerTicked، إذا كانت مدة النبضة أكبر من 0 فنحن بحاجة إلى إصدار حالة TimerRunInProgress محدثة بالمدة الجديدة. أما إذا كانت مدة النبضة تساوي 0 فقد انتهى المؤقّت ونحتاج إلى إصدار حالة TimerRunComplete.

الآن دعنا ننفذ معالج حدث 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(),
);
}
}

في _onPaused، إذا كانت state الخاصة بـ TimerBloc هي TimerRunInProgress فيمكننا إيقاف _tickerSubscription مؤقتًا وإصدار حالة TimerRunPause بالمدة الحالية.

بعد ذلك، لننفذ معالج حدث TimerResumed حتى نستأنف المؤقّت.

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(),
);
}
}

معالج حدث TimerResumed مشابه جدًا لمعالج حدث TimerPaused. إذا كانت حالة TimerBloc من نوع TimerRunPause ووصل حدث TimerResumed، فإنه يستأنف _tickerSubscription ويصدر حالة TimerRunInProgress بالمدة الحالية.

أخيرًا، نحتاج إلى تنفيذ معالج حدث 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 Ticker ticker})
: _ticker = 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(),
);
}
}

إذا استقبل TimerBloc حدث TimerReset، فإنه يحتاج إلى إلغاء _tickerSubscription الحالية حتى لا يتلقى أي نبضات إضافية، ثم يصدر حالة TimerInitial بالمدة الأصلية.

هذا كل ما يخص TimerBloc. والمتبقي الآن هو تنفيذ واجهة المستخدم (UI) للتطبيق.

واجهة مستخدم التطبيق (Application UI)

Section titled “واجهة مستخدم التطبيق (Application UI)”

يمكننا البدء بحذف محتويات main.dart واستبدالها بما يلي.

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

بعد ذلك، لننشئ Widget التطبيق في app.dart، والتي ستكون جذر التطبيق.

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(),
);
}
}

بعد ذلك، نحتاج إلى تنفيذ Widget Timer.

Widget Timer في (lib/timer/view/timer_page.dart) مسؤولة عن عرض الوقت المتبقي مع الأزرار المناسبة التي تمكّن المستخدم من بدء المؤقّت وإيقافه مؤقتًا وإعادة تعيينه.

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),
);
}
}

حتى الآن، نستخدم BlocProvider فقط للوصول إلى instance من TimerBloc.

بعد ذلك، سننفّذ Widget Actions والتي ستحتوي على الإجراءات المناسبة (بدء، إيقاف مؤقت، وإعادة تعيين).

لتنظيم عمليات الاستيراد من قسم Timer، نحتاج إلى إنشاء ملف تجميعي (barrel file) باسم 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()),
),
]
}
],
);
},
);
}
}

Widget Actions هي StatelessWidget تستخدم BlocBuilder لإعادة بناء واجهة المستخدم كلما حصلنا على TimerState جديدة. تستخدم Actions الدالة context.read<TimerBloc>() للوصول إلى instance من TimerBloc، وتُرجع أزرار FloatingActionButton مختلفة حسب الحالة الحالية لـ TimerBloc. كل زر من أزرار FloatingActionButton يضيف event داخل callback onPressed لإخطار TimerBloc.

إذا أردت تحكمًا أدق في توقيت استدعاء builder، يمكنك تمرير buildWhen اختياريًا إلى BlocBuilder. تستقبل buildWhen الحالة السابقة والحالة الحالية للـ bloc وتعيد قيمة منطقية (boolean). إذا أعادت true فسيُستدعى builder بالحالة وتحدث إعادة البناء. وإذا أعادت false فلن يُستدعى builder ولن تحدث إعادة بناء.

في هذه الحالة، لا نريد إعادة بناء Widget Actions في كل نبضة لأن ذلك غير فعّال. بدلًا من ذلك، نريد إعادة بناء Actions فقط إذا تغيّر runtimeType لـ TimerState (على سبيل المثال: TimerInitial => TimerRunInProgress، TimerRunInProgress => TimerRunPause، إلخ…).

نتيجةً لذلك، إذا قمنا بتلوين الـ Widgets عشوائيًا عند كل إعادة بناء، فسيبدو الأمر كما يلي:

BlocBuilder buildWhen demo

أخيرًا، أضف Widget الخلفية كما يلي:

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,
],
),
),
);
}
}

هذا كل ما في الأمر! في هذه المرحلة أصبح لدينا تطبيق مؤقّت جيد يعيد بناء Widgets التي تحتاج فقط إلى إعادة البناء بكفاءة.

يمكن العثور على المصدر الكامل لهذا المثال هنا.