- 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)
344 lines
13 KiB
Dart
344 lines
13 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:habo/habits/habits_manager.dart';
|
|
import 'package:habo/navigation/app_state_manager.dart';
|
|
import 'package:habo/constants.dart';
|
|
|
|
class CreateHabitScreen extends StatefulWidget {
|
|
const CreateHabitScreen({super.key});
|
|
|
|
@override
|
|
State<CreateHabitScreen> createState() => _CreateHabitScreenState();
|
|
}
|
|
|
|
class _CreateHabitScreenState extends State<CreateHabitScreen> {
|
|
final _titleController = TextEditingController();
|
|
final _cueController = TextEditingController();
|
|
final _routineController = TextEditingController();
|
|
final _rewardController = TextEditingController();
|
|
final _sanctionController = TextEditingController();
|
|
final _accountantController = TextEditingController();
|
|
final _targetValueController = TextEditingController(text: '100');
|
|
final _partialValueController = TextEditingController(text: '10');
|
|
final _unitController = TextEditingController();
|
|
|
|
bool _twoDayRule = false;
|
|
bool _notification = false;
|
|
bool _advanced = false;
|
|
bool _habitContract = false;
|
|
bool _showReward = false;
|
|
bool _showSanction = false;
|
|
HabitType _habitType = HabitType.boolean;
|
|
TimeOfDay _notTime = const TimeOfDay(hour: 20, minute: 0);
|
|
|
|
@override
|
|
void dispose() {
|
|
_titleController.dispose();
|
|
_cueController.dispose();
|
|
_routineController.dispose();
|
|
_rewardController.dispose();
|
|
_sanctionController.dispose();
|
|
_accountantController.dispose();
|
|
_targetValueController.dispose();
|
|
_partialValueController.dispose();
|
|
_unitController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text('Create Habit'),
|
|
leading: IconButton(
|
|
icon: const Icon(Icons.arrow_back),
|
|
onPressed: () => context.read<AppStateManager>().goCreateHabit(false),
|
|
),
|
|
actions: [
|
|
IconButton(
|
|
icon: const Icon(Icons.check, color: HaboColors.primary),
|
|
onPressed: _save,
|
|
),
|
|
],
|
|
),
|
|
body: ListView(
|
|
padding: const EdgeInsets.all(16),
|
|
children: [
|
|
// Title
|
|
TextField(
|
|
controller: _titleController,
|
|
autofocus: true,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Title *',
|
|
hintText: 'e.g. Exercise, Reading, Meditation',
|
|
prefixIcon: Icon(Icons.title),
|
|
border: OutlineInputBorder(),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// Habit Type
|
|
Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Habit Type',
|
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
RadioListTile<HabitType>(
|
|
title: const Text('Checkable (Yes/No)'),
|
|
subtitle: const Text('Simple daily check-in'),
|
|
secondary: const Icon(Icons.check_box),
|
|
value: HabitType.boolean,
|
|
groupValue: _habitType,
|
|
onChanged: (v) => setState(() => _habitType = v!),
|
|
),
|
|
RadioListTile<HabitType>(
|
|
title: const Text('Progressive (Numeric)'),
|
|
subtitle: const Text('Track counts like steps, glasses'),
|
|
secondary: const Icon(Icons.track_changes),
|
|
value: HabitType.numeric,
|
|
groupValue: _habitType,
|
|
onChanged: (v) => setState(() => _habitType = v!),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
|
|
// Numeric fields
|
|
if (_habitType == HabitType.numeric)
|
|
Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Progress Settings',
|
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
TextField(
|
|
controller: _targetValueController,
|
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
|
decoration: const InputDecoration(
|
|
labelText: 'Target Value',
|
|
prefixIcon: Icon(Icons.flag),
|
|
border: OutlineInputBorder(),
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
TextField(
|
|
controller: _partialValueController,
|
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
|
decoration: const InputDecoration(
|
|
labelText: 'Partial Value (increment)',
|
|
prefixIcon: Icon(Icons.add),
|
|
border: OutlineInputBorder(),
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
TextField(
|
|
controller: _unitController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Unit',
|
|
hintText: 'e.g. steps, km, glasses',
|
|
prefixIcon: Icon(Icons.straighten),
|
|
border: OutlineInputBorder(),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
|
|
// Two Day Rule
|
|
Card(
|
|
child: SwitchListTile(
|
|
title: const Text('Two Day Rule'),
|
|
subtitle: const Text('Allow one miss without losing streak'),
|
|
secondary: const Icon(Icons.rule),
|
|
value: _twoDayRule,
|
|
onChanged: (v) => setState(() => _twoDayRule = v),
|
|
),
|
|
),
|
|
|
|
// Notifications
|
|
Card(
|
|
child: Column(
|
|
children: [
|
|
SwitchListTile(
|
|
title: const Text('Daily Reminder'),
|
|
subtitle: const Text('Get notified to check habits'),
|
|
secondary: const Icon(Icons.notifications),
|
|
value: _notification,
|
|
onChanged: (v) => setState(() => _notification = v),
|
|
),
|
|
if (_notification)
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
|
child: OutlinedButton.icon(
|
|
onPressed: _pickTime,
|
|
icon: const Icon(Icons.access_time),
|
|
label: Text('${_notTime.format(context)}'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
// Advanced Options
|
|
ExpansionTile(
|
|
leading: const Icon(Icons.tune),
|
|
title: const Text('Advanced Options'),
|
|
subtitle: const Text('Cue, Routine, Reward, Habit Contract'),
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
children: [
|
|
TextField(
|
|
controller: _cueController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Cue (Trigger)',
|
|
hintText: 'e.g. At 7:00 AM',
|
|
prefixIcon: Icon(Icons.flash_on),
|
|
border: OutlineInputBorder(),
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
TextField(
|
|
controller: _routineController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Routine (Action)',
|
|
hintText: 'e.g. Do 50 push ups',
|
|
prefixIcon: Icon(Icons.fitness_center),
|
|
border: OutlineInputBorder(),
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: TextField(
|
|
controller: _rewardController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Reward',
|
|
hintText: 'e.g. 15 min games',
|
|
prefixIcon: Icon(Icons.emoji_events),
|
|
border: OutlineInputBorder(),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Switch(value: _showReward, onChanged: (v) => setState(() => _showReward = v)),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const Divider(),
|
|
SwitchListTile(
|
|
title: const Text('Habit Contract'),
|
|
subtitle: const Text('Set a sanction for missing'),
|
|
secondary: const Icon(Icons.gavel),
|
|
value: _habitContract,
|
|
onChanged: (v) => setState(() => _habitContract = v),
|
|
),
|
|
if (_habitContract)
|
|
Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
children: [
|
|
TextField(
|
|
controller: _sanctionController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Sanction',
|
|
hintText: 'e.g. Donate \$10 to charity',
|
|
prefixIcon: Icon(Icons.warning),
|
|
border: OutlineInputBorder(),
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
TextField(
|
|
controller: _accountantController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Accountability Partner',
|
|
hintText: 'e.g. Dan',
|
|
prefixIcon: Icon(Icons.person),
|
|
border: OutlineInputBorder(),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 24),
|
|
|
|
// Save button
|
|
SizedBox(
|
|
width: double.infinity,
|
|
height: 48,
|
|
child: ElevatedButton.icon(
|
|
onPressed: _save,
|
|
icon: const Icon(Icons.check),
|
|
label: const Text('Save Habit', style: TextStyle(fontSize: 16)),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: HaboColors.primary,
|
|
foregroundColor: Colors.white,
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _pickTime() async {
|
|
final picked = await showTimePicker(context: context, initialTime: _notTime);
|
|
if (picked != null) setState(() => _notTime = picked);
|
|
}
|
|
|
|
void _save() {
|
|
if (_titleController.text.trim().isEmpty) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Title cannot be empty!'), backgroundColor: HaboColors.red),
|
|
);
|
|
return;
|
|
}
|
|
|
|
context.read<HabitsManager>().addHabit(
|
|
_titleController.text.trim(),
|
|
_twoDayRule,
|
|
_cueController.text,
|
|
_routineController.text,
|
|
_rewardController.text,
|
|
_showReward,
|
|
_advanced || _habitContract,
|
|
_notification,
|
|
_notTime,
|
|
_sanctionController.text,
|
|
_showSanction,
|
|
_accountantController.text,
|
|
habitType: _habitType,
|
|
targetValue: double.tryParse(_targetValueController.text) ?? 100.0,
|
|
partialValue: double.tryParse(_partialValueController.text) ?? 10.0,
|
|
unit: _unitController.text,
|
|
);
|
|
|
|
context.read<AppStateManager>().goCreateHabit(false);
|
|
}
|
|
}
|