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

متتبع العادات: أضف عادة

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

يتتبّع كل شخص عادات مختلفة، لذا يحتاج التطبيق إلى طريقة لإضافة عادة. يفتح زر FloatingActionButton دائري في الزاوية مربع حوار يطلب الاسم.

تعيد showDialog قيمة Future تحتوي على ما يُرجعه مربع الحوار. هنا يُرجع مربع الحوار true عند الإضافة وfalse عند الإلغاء، ويكتب الحقل ما تكتبه في متغير باستخدام onChanged:

String name = '';
final added = await showDialog<bool>(
  context: context,
  builder: (context) => AlertDialog(
    title: const Text('New habit'),
    content: TextField(onChanged: (value) => name = value, ...),
    actions: [...],
  ),
);
if (added != true || name.trim().isEmpty) return;

نوع added هو bool?: يؤدي النقر خارج مربع الحوار إلى إغلاقه مع القيمة null. ويغطي الشرط added != true حالتي الإلغاء وإغلاق مربع الحوار بهذه الطريقة معًا.

تبدأ العادة الجديدة بأسبوع جديد: List.filled(7, false). وليس const [false, ...]: إذ لا يمكن تغيير قائمة const مطلقًا، لذا ستؤدي أول علامة تحديد إلى توقّف التطبيق عن العمل.

challenge icon

التحدي

صعب

أضف عادات:

  • أضف إلى Scaffold عنصر FloatingActionButton يتضمن Icons.add ويستدعي _addHabit
  • اكتب Future<void> _addHabit() async: مربع حوار AlertDialog بعنوان New habit يتضمن TextField بتسمية Habit name (مع autofocus: true)، وزر TextButton بعنوان Cancel وزر FilledButton بعنوان Add
  • بعد الضغط على Add، إذا كان الاسم غير فارغ، أضف عادةً بهذا الاسم بعد إزالة المسافات الزائدة، وأسبوعًا فارغًا

جرّب بنفسك

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

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Habits')),
      body: ListView(
        padding: const EdgeInsets.fromLTRB(16, 8, 16, 88),
        children: [
          for (Habit habit in _habits)
            HabitCard(
              habit: habit,
              onToggle: (day) => _toggle(habit, day),
            ),
        ],
      ),
    );
  }
}

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

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

  @override
  Widget build(BuildContext context) {
    final textTheme = Theme.of(context).textTheme;
    return Card(
      margin: const EdgeInsets.only(top: 12),
      clipBehavior: Clip.antiAlias,
      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 عبر الإنترنت