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

المواضيع الرئيسية
Section titled “المواضيع الرئيسية”- مراقبة تغييرات الحالة باستخدام BlocObserver.
- BlocProvider، وهي Widget في Flutter توفّر Bloc للأبناء.
- BlocBuilder، وهي Widget في Flutter تتولّى إعادة البناء استجابةً للحالات الجديدة.
- منع إعادة البناء غير الضرورية باستخدام Equatable.
- تعلم استخدام
StreamSubscriptionداخل Bloc. - منع إعادة البناء غير الضرورية باستخدام
buildWhen.
الإعداد
Section titled “الإعداد”سنبدأ بإنشاء مشروع Flutter جديد بالكامل:
flutter create flutter_timerبعد ذلك، يمكننا استبدال محتويات pubspec.yaml بما يلي:
name: flutter_timerdescription: A new Flutter project.version: 1.0.0+1publish_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).
هيكل المشروع
Section titled “هيكل المشروع”├── 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)
Section titled “المؤقت (Ticker)”سيكون Ticker هو مصدر البيانات لتطبيق المؤقّت. إذ يوفّر stream من النبضات
(ticks) يمكننا الاشتراك فيه والتفاعل معه.
ابدأ بإنشاء الملف 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.
Timer Bloc
Section titled “Timer Bloc”حالة المؤقت (TimerState)
Section titled “حالة المؤقت (TimerState)”سنبدأ بتعريف TimerStates التي يمكن أن تكون عليها TimerBloc.
يمكن أن تكون حالة TimerBloc الخاصة بنا واحدة مما يلي:
TimerInitial: جاهز لبدء العد التنازلي من المدة المحددة.TimerRunInProgress: يعد تنازليًا بنشاط من المدة المحددة.TimerRunPause: متوقف مؤقتًا عند مدة متبقية معينة.TimerRunComplete: اكتمل بمدة متبقية 0.
كل حالة من هذه الحالات تؤثر على واجهة المستخدم والإجراءات المتاحة للمستخدم. على سبيل المثال:
- إذا كانت الحالة هي
TimerInitial، فسيتمكن المستخدم من بدء المؤقت. - إذا كانت الحالة هي
TimerRunInProgress، فسيتمكن المستخدم من إيقاف المؤقت مؤقتًا وإعادة تعيينه، بالإضافة إلى رؤية المدة المتبقية. - إذا كانت الحالة هي
TimerRunPause، فسيتمكن المستخدم من استئناف المؤقت وإعادة تعيينه. - إذا كانت الحالة هي
TimerRunComplete، فسيتمكن المستخدم من إعادة تعيين المؤقت.
للحفاظ على جميع ملفات bloc معًا، لننشئ مجلد bloc ونضيف
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.
حدث المؤقت (TimerEvent)
Section titled “حدث المؤقت (TimerEvent)”يحتاج TimerBloc إلى معرفة كيفية معالجة الأحداث التالية:
TimerStarted: يُعلمTimerBlocبضرورة بدء المؤقت.TimerPaused: يُعلمTimerBlocبضرورة إيقاف المؤقت مؤقتًا.TimerResumed: يُعلمTimerBlocبضرورة استئناف المؤقت.TimerReset: يُعلمTimerBlocبضرورة إعادة تعيين المؤقت إلى حالته الأصلية._TimerTicked: يُعلمTimerBlocبحدوث نبضة (tick) وبضرورة تحديث حالته وفقًا لذلك.
إذا لم تستخدم إضافات
IntelliJ أو
VSCode،
فأنشئ 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.
TimerBloc
Section titled “TimerBloc”إذا لم تكن قد فعلت ذلك بعد، فأنشئ bloc/timer_bloc.dart وأنشئ TimerBloc
فارغًا.
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 ثانية).
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.
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.
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.
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.
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 حتى نستأنف المؤقّت.
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.
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 واستبدالها بما يلي.
import 'package:flutter/material.dart';import 'package:flutter_timer/app.dart';
void main() => runApp(const App());بعد ذلك، لننشئ Widget التطبيق في 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.
المؤقت (Timer)
Section titled “المؤقت (Timer)”Widget 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), ); }}حتى الآن، نستخدم BlocProvider فقط للوصول إلى instance من TimerBloc.
بعد ذلك، سننفّذ Widget Actions والتي ستحتوي على الإجراءات المناسبة (بدء، إيقاف
مؤقت، وإعادة تعيين).
ملف التجميع (Barrel)
Section titled “ملف التجميع (Barrel)”لتنظيم عمليات الاستيراد من قسم Timer، نحتاج إلى إنشاء ملف تجميعي
(barrel file) باسم timer/timer.dart.
export 'bloc/timer_bloc.dart';export 'view/timer_page.dart';الإجراءات (Actions)
Section titled “الإجراءات (Actions)”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 عشوائيًا عند كل إعادة بناء، فسيبدو الأمر كما يلي:

الخلفية (Background)
Section titled “الخلفية (Background)”أخيرًا، أضف Widget الخلفية كما يلي:
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, ], ), ), ); }}تجميع كل شيء معًا
Section titled “تجميع كل شيء معًا”هذا كل ما في الأمر! في هذه المرحلة أصبح لدينا تطبيق مؤقّت جيد يعيد بناء Widgets التي تحتاج فقط إلى إعادة البناء بكفاءة.
يمكن العثور على المصدر الكامل لهذا المثال هنا.