- Complete MVP with Repository Pattern, SQLite storage - Provider + ChangeNotifier state management - Navigation 2.0 with deep link support - Habit CRUD with twoDayRule, notifications, categories - Backup/Restore via JSON - Statistics with streak tracking - Material You theme support - Biometric lock support - Desktop widget support - 27 languages i18n structure - Comprehensive test suite (87/89 passing)
336 lines
10 KiB
Dart
336 lines
10 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:habo/habits/habits_manager.dart';
|
|
import 'package:habo/habits/habit.dart';
|
|
import 'package:habo/model/habit_data.dart';
|
|
import 'package:habo/habits/calendar_column.dart';
|
|
import 'package:habo/settings/settings_manager.dart';
|
|
import 'package:habo/navigation/app_state_manager.dart';
|
|
import 'package:habo/constants.dart';
|
|
import 'package:reorderables/reorderables.dart';
|
|
|
|
class HabitsScreen extends StatefulWidget {
|
|
const HabitsScreen({super.key});
|
|
|
|
@override
|
|
State<HabitsScreen> createState() => _HabitsScreenState();
|
|
}
|
|
|
|
class _HabitsScreenState extends State<HabitsScreen> {
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final habitsManager = context.watch<HabitsManager>();
|
|
final settings = context.watch<SettingsManager>();
|
|
final appState = context.read<AppStateManager>();
|
|
final activeHabits = habitsManager.activeHabits;
|
|
|
|
return Scaffold(
|
|
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
|
appBar: AppBar(
|
|
title: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
'Habo',
|
|
style: TextStyle(
|
|
color: HaboColors.primary,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Icon(Icons.check_circle, color: HaboColors.primary, size: 20),
|
|
],
|
|
),
|
|
actions: [
|
|
IconButton(
|
|
icon: const Icon(Icons.bar_chart),
|
|
tooltip: 'Statistics',
|
|
onPressed: () => appState.goStatistics(true),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.settings),
|
|
tooltip: 'Settings',
|
|
onPressed: () => appState.goSettings(true),
|
|
),
|
|
],
|
|
backgroundColor: Theme.of(context).appBarTheme.backgroundColor,
|
|
),
|
|
body: activeHabits.isEmpty
|
|
? _buildEmptyState(context)
|
|
: _buildHabitList(context, activeHabits),
|
|
floatingActionButton: FloatingActionButton(
|
|
backgroundColor: HaboColors.primary,
|
|
child: const Icon(Icons.add, color: Colors.white),
|
|
onPressed: () => appState.goCreateHabit(true),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildEmptyState(BuildContext context) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Image.asset(
|
|
'assets/images/emptyList.svg',
|
|
width: 200,
|
|
height: 200,
|
|
errorBuilder: (_, __, ___) => Icon(
|
|
Icons.track_changes,
|
|
size: 120,
|
|
color: Colors.grey.shade300,
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
Text(
|
|
'Empty list',
|
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
|
color: Colors.grey.shade600,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'Create your first habit.',
|
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
|
color: Colors.grey.shade500,
|
|
),
|
|
),
|
|
const SizedBox(height: 32),
|
|
ElevatedButton.icon(
|
|
onPressed: () => context.read<AppStateManager>().goCreateHabit(true),
|
|
icon: const Icon(Icons.add),
|
|
label: const Text('Create your first habit'),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: HaboColors.primary,
|
|
foregroundColor: Colors.white,
|
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildHabitList(BuildContext context, List<Habit> habits) {
|
|
return Column(
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
|
child: Row(
|
|
children: [
|
|
Text(
|
|
'Habits:',
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
'${habits.length}',
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
color: HaboColors.primary,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Expanded(
|
|
child: ReorderableColumn(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: habits
|
|
.map((habit) => Padding(
|
|
key: ValueKey(habit.habitData.id),
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
|
child: HabitCard(habit: habit),
|
|
))
|
|
.toList(),
|
|
onReorder: (oldIndex, newIndex) {
|
|
context.read<HabitsManager>().reorderList(oldIndex, newIndex);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class HabitCard extends StatelessWidget {
|
|
final Habit habit;
|
|
|
|
const HabitCard({super.key, required this.habit});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final data = habit.habitData;
|
|
return Card(
|
|
elevation: 2,
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Title row
|
|
Row(
|
|
children: [
|
|
Icon(
|
|
data.archived ? Icons.archive : Icons.check_circle,
|
|
color: data.archived ? Colors.grey : HaboColors.primary,
|
|
size: 22,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
data.title,
|
|
style: const TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
// Streak badge
|
|
if (data.streakVisible)
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color: HaboColors.primary,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Text(
|
|
'🔥 ${data.streak} days',
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
),
|
|
if (data.orangeStreak)
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color: HaboColors.orange,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Text(
|
|
'⚠️ ${data.streak} days',
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
// Mini calendar - last 14 days
|
|
_buildMiniCalendar(context),
|
|
const SizedBox(height: 8),
|
|
// Info row
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(
|
|
data.twoDayRule ? '📏 Two Day Rule ON' : '',
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: Colors.grey.shade500,
|
|
),
|
|
),
|
|
if (data.habitType == HabitType.numeric)
|
|
Text(
|
|
'📊 ${data.targetValue} ${data.unit}',
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: Colors.blue.shade600,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildMiniCalendar(BuildContext context) {
|
|
final today = DateTime.now();
|
|
final dots = <Widget>[];
|
|
|
|
for (int i = 13; i >= 0; i--) {
|
|
final date = DateTime(today.year, today.month, today.day - i);
|
|
final event = data.events[date];
|
|
Color dotColor = Colors.grey.shade300;
|
|
String label = _shortDay(date);
|
|
|
|
if (event != null) {
|
|
final dayType = event[0] is DayType ? event[0] as DayType : DayType.values[event[0] as int];
|
|
switch (dayType) {
|
|
case DayType.check:
|
|
dotColor = HaboColors.primary;
|
|
break;
|
|
case DayType.fail:
|
|
dotColor = HaboColors.red;
|
|
break;
|
|
case DayType.skip:
|
|
dotColor = HaboColors.skip;
|
|
break;
|
|
case DayType.progress:
|
|
dotColor = HaboColors.progress;
|
|
break;
|
|
case DayType.clear:
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Check if today
|
|
final isToday = i == 0;
|
|
|
|
dots.add(
|
|
Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontSize: 9,
|
|
color: isToday ? HaboColors.primary : Colors.grey.shade500,
|
|
fontWeight: isToday ? FontWeight.bold : FontWeight.normal,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Container(
|
|
width: 18,
|
|
height: 18,
|
|
decoration: BoxDecoration(
|
|
color: dotColor,
|
|
shape: BoxShape.circle,
|
|
border: isToday
|
|
? Border.all(color: HaboColors.primary, width: 2)
|
|
: null,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
return Wrap(
|
|
spacing: 6,
|
|
runSpacing: 2,
|
|
children: dots,
|
|
);
|
|
}
|
|
|
|
String _shortDay(DateTime date) {
|
|
const days = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
|
|
return days[date.weekday % 7];
|
|
}
|
|
|
|
// Need access to habit data
|
|
HabitData get data => habit.habitData;
|
|
}
|