Menu
CoddyTech

מעקב הרגלים: בהיר וכהה

חלק מהיחידה פרויקטים במסלול ה-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 אונליין