Menu
CoddyTech

Rastreador de hábitos: Anillo de progreso

Parte de la sección Proyectos del Journey de Flutter de Coddy. Lección 35 de 36.

¿Cómo va la semana en general? Un anillo en la parte superior responde de un vistazo: se va llenando a medida que marcas casillas, con el porcentaje en el centro. Es un CircularProgressIndicator con un value, el equivalente circular de la barra de progreso del cuestionario.

Los números provienen de todos los hábitos: las casillas marcadas, de un total de siete por hábito. Un getter los suma con fold, como hacía el total de la tienda:

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

El anillo y el porcentaje se superponen, así que van en un Stack con fit: StackFit.expand dentro de un SizedBox de 72 por 72. El anillo llena el cuadro y un Center coloca el texto en el centro.

Si se eliminan todos los hábitos, _possible es 0, y 0 / 0 no es un número. En ese caso, muestra 0: possible == 0 ? 0.0 : ticks / possible. Para la etiqueta, (progress * 100).round() da un porcentaje entero.

challenge icon

Desafío

Difícil

Muestra el progreso de la semana:

  • añade _ticks y _possible al estado de la pantalla
  • escribe un ProgressCard que reciba ticks y possible: un Card con margin: EdgeInsets.zero en el primaryContainer del tema, con 20 píxeles de relleno alrededor de un Row
  • la fila: el anillo de 72 píxeles (strokeWidth: 8, strokeCap: StrokeCap.round, backgroundColor: colors.surface) con 33% en negrita y titleMedium en el centro, 20 píxeles y después una Column Expanded con This week en titleLarge, 4 píxeles y 7 of 21 ticks
  • haz que sea el primer elemento secundario de la lista

Pruébalo tú mismo

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

  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: [
          for (Habit habit in _habits)
            HabitCard(
              habit: habit,
              onToggle: (day) => _toggle(habit, day),
              onLongPress: () => _deleteHabit(habit),
            ),
        ],
      ),
    );
  }
}

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 iconPonte a prueba

Esta lección incluye un breve cuestionario. Empieza la lección para responderlo y registrar tu progreso.

Todas las lecciones de Proyectos

Practica por tu cuenta: Compilador de Flutter online