Menu
CoddyTech
flag Ar iconالعربيةdown icon

متتبع العادات: الفاتح والداكن

جزء من قسم المشاريع في رحلة Flutter على Coddy. الدرس 36 من 36.

يتفقد الناس عاداتهم في وقت متأخر من الليل، لذا فإن الميزة الأخيرة هي الوضع الداكن، مع زر في الشريط العلوي: قمر لتفعيله، وشمس لإعادته إلى الوضع الفاتح.

يُضبط المظهر في MaterialApp، لذا يُضبط التبديل هناك أيضًا: يصبح MyApp عنصرًا من نوع StatefulWidget مع قيمة bool _dark. يحصل MaterialApp على كلا المظهرين ويختار أحدهما باستخدام themeMode:

darkTheme: ThemeData(
  colorScheme: ColorScheme.fromSeed(
    seedColor: Colors.green,
    brightness: Brightness.dark,
  ),
),
themeMode: _dark ? ThemeMode.dark : ThemeMode.light,

يوجد الزر في شاشة العادات، بعيدًا أسفل MyApp. ارفع الحالة مرة أخرى: يحصل HabitScreen على dark لاختيار الأيقونة، وعلى رد نداء onToggleTheme لاستدعائه. تقرأ الشاشة هاتين القيمتين من عنصرها على النحو التالي: widget.dark وwidget.onToggleTheme.

كان كل لون في التطبيق مستمدًا من المظهر، لذا تتبدل البطاقات والدوائر والحلقة معه. وتظل عاداتك محددة، لأنها محفوظة في حالة الشاشة، لا في المظهر.

هذا هو الدرس الأخير في مشروعات الدورة. تستخدم الاختبارات أداة التتبع كاملة: الإضافة، والتحديد، والتتابع، والحذف، والحلقة، والوضع الداكن.

challenge icon

التحدي

صعب

أضف الوضع الداكن:

  • اجعل MyApp عنصر StatefulWidget مع bool _dark = false، وأضف إلى MaterialApp سمة darkTheme باستخدام بذرة اللون الأخضر نفسها مع Brightness.dark، واضبط themeMode استنادًا إلى _dark
  • أضف إلى HabitScreen الخاصية required bool dark وVoidCallback onToggleTheme؛ يمرر MyApp القيمة _dark ودالةً تعكسها داخل setState
  • في actions لشريط التنقل العلوي، يعرض IconButton الرمز Icons.dark_mode في الوضع الفاتح والرمز Icons.light_mode في الوضع الداكن، ويستدعي widget.onToggleTheme

جرّب بنفسك

import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.green),
      ),
      home: const HabitScreen(),
    );
  }
}

const List<String> dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];

class Habit {
  Habit(this.name, this.days);

  final String name;
  final List<bool> days;

  int get done => days.where((day) => day).length;

  int get streak {
    int best = 0;
    int run = 0;
    for (bool day in days) {
      if (day) {
        run++;
        if (run > best) best = run;
      } else {
        run = 0;
      }
    }
    return best;
  }
}

class HabitScreen extends StatefulWidget {
  const HabitScreen({super.key});

  @override
  State<HabitScreen> createState() => _HabitScreenState();
}

class _HabitScreenState extends State<HabitScreen> {
  final List<Habit> _habits = [
    Habit('Drink water', [true, true, true, false, true, false, false]),
    Habit('Read 10 pages', [true, false, true, true, false, false, false]),
    Habit('Stretch', List.filled(7, false)),
  ];

  int get _ticks => _habits.fold(0, (sum, habit) => sum + habit.done);
  int get _possible => _habits.length * 7;

  void _toggle(Habit habit, int day) {
    setState(() {
      habit.days[day] = !habit.days[day];
    });
  }

  Future<void> _addHabit() async {
    String name = '';
    final added = await showDialog<bool>(
      context: context,
      builder: (context) => AlertDialog(
        title: const Text('New habit'),
        content: TextField(
          autofocus: true,
          decoration: const InputDecoration(labelText: 'Habit name'),
          onChanged: (value) => name = value,
        ),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context, false),
            child: const Text('Cancel'),
          ),
          FilledButton(
            onPressed: () => Navigator.pop(context, true),
            child: const Text('Add'),
          ),
        ],
      ),
    );
    if (added != true || name.trim().isEmpty) return;
    setState(() {
      _habits.add(Habit(name.trim(), List.filled(7, false)));
    });
  }

  Future<void> _deleteHabit(Habit habit) async {
    final delete = await showDialog<bool>(
      context: context,
      builder: (context) => AlertDialog(
        title: Text('Delete ${habit.name}?'),
        content: const Text('Its ticks for this week go with it.'),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context, false),
            child: const Text('Cancel'),
          ),
          FilledButton(
            onPressed: () => Navigator.pop(context, true),
            child: const Text('Delete'),
          ),
        ],
      ),
    );
    if (delete != true) return;
    setState(() {
      _habits.remove(habit);
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Habits')),
      floatingActionButton: FloatingActionButton(
        onPressed: _addHabit,
        child: const Icon(Icons.add),
      ),
      body: ListView(
        padding: const EdgeInsets.fromLTRB(16, 8, 16, 88),
        children: [
          ProgressCard(ticks: _ticks, possible: _possible),
          for (Habit habit in _habits)
            HabitCard(
              habit: habit,
              onToggle: (day) => _toggle(habit, day),
              onLongPress: () => _deleteHabit(habit),
            ),
        ],
      ),
    );
  }
}

class ProgressCard extends StatelessWidget {
  const ProgressCard({super.key, required this.ticks, required this.possible});

  final int ticks;
  final int possible;

  @override
  Widget build(BuildContext context) {
    final colors = Theme.of(context).colorScheme;
    final textTheme = Theme.of(context).textTheme;
    final progress = possible == 0 ? 0.0 : ticks / possible;
    return Card(
      margin: EdgeInsets.zero,
      color: colors.primaryContainer,
      child: Padding(
        padding: const EdgeInsets.all(20),
        child: Row(
          children: [
            SizedBox(
              width: 72,
              height: 72,
              child: Stack(
                fit: StackFit.expand,
                children: [
                  CircularProgressIndicator(
                    value: progress,
                    strokeWidth: 8,
                    strokeCap: StrokeCap.round,
                    backgroundColor: colors.surface,
                  ),
                  Center(
                    child: Text(
                      '${(progress * 100).round()}%',
                      style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
                    ),
                  ),
                ],
              ),
            ),
            const SizedBox(width: 20),
            Expanded(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text('This week', style: textTheme.titleLarge),
                  const SizedBox(height: 4),
                  Text('$ticks of $possible ticks'),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }
}

class HabitCard extends StatelessWidget {
  const HabitCard({
    super.key,
    required this.habit,
    required this.onToggle,
    required this.onLongPress,
  });

  final Habit habit;
  final void Function(int day) onToggle;
  final VoidCallback onLongPress;

  @override
  Widget build(BuildContext context) {
    final textTheme = Theme.of(context).textTheme;
    return Card(
      margin: const EdgeInsets.only(top: 12),
      clipBehavior: Clip.antiAlias,
      child: InkWell(
        onLongPress: onLongPress,
        child: Padding(
          padding: const EdgeInsets.all(16),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Row(
                children: [
                  Expanded(child: Text(habit.name, style: textTheme.titleMedium)),
                  Text('${habit.done} of 7 days', style: textTheme.bodySmall),
                ],
              ),
              const SizedBox(height: 12),
              Row(
                mainAxisAlignment: MainAxisAlignment.spaceBetween,
                children: [
                  for (int i = 0; i < 7; i++)
                    DayCircle(
                      label: dayNames[i],
                      done: habit.days[i],
                      onTap: () => onToggle(i),
                    ),
                ],
              ),
              const SizedBox(height: 12),
              Row(
                children: [
                  Icon(
                    Icons.local_fire_department,
                    size: 20,
                    color: habit.streak > 0 ? Colors.orange : Theme.of(context).colorScheme.outline,
                  ),
                  const SizedBox(width: 4),
                  Text(habit.streak == 0 ? 'No streak yet' : '${habit.streak} day streak'),
                ],
              ),
            ],
          ),
        ),
      ),
    );
  }
}

class DayCircle extends StatelessWidget {
  const DayCircle({super.key, required this.label, required this.done, required this.onTap});

  final String label;
  final bool done;
  final VoidCallback onTap;

  @override
  Widget build(BuildContext context) {
    final colors = Theme.of(context).colorScheme;
    return GestureDetector(
      onTap: onTap,
      child: Container(
        width: 40,
        height: 40,
        alignment: Alignment.center,
        decoration: BoxDecoration(
          shape: BoxShape.circle,
          color: done ? colors.primary : colors.surfaceContainerHighest,
        ),
        child: Text(
          label,
          style: TextStyle(
            fontSize: 11,
            fontWeight: FontWeight.bold,
            color: done ? colors.onPrimary : colors.onSurfaceVariant,
          ),
        ),
      ),
    );
  }
}
quiz iconاختبر نفسك

يتضمن هذا الدرس اختبارًا قصيرًا. ابدأ الدرس للإجابة عليه وتتبّع تقدمك.

جميع دروس المشاريع

تدرّب بنفسك: مترجم Flutter عبر الإنترنت