feat: initial commit - Habo habit tracking app

- 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)
This commit is contained in:
2026-04-13 15:02:30 +00:00
commit aa69f2a91e
212 changed files with 16694 additions and 0 deletions

37
lib/constants.dart Normal file
View File

@@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
// ─── Enums ───────────────────────────────────────────────────────────────────
enum HabitType { boolean, numeric }
enum DayType { clear, check, fail, skip, progress }
enum Themes { device, light, dark, oled, materialYou }
// ─── Color Constants ─────────────────────────────────────────────────────────
class HaboColors {
static const primary = Color(0xFF09BF30);
static const red = Color(0xFFF44336);
static const skip = Color(0xFFFBC02D);
static const orange = Color(0xFFFF9800);
static const progress = Color(0xFF2196F3);
static const progressBg = Color(0xFFE3F2FD);
static const lightBg = Color(0xFFFAFAFA);
static const darkBg = Color(0xFF000000);
static const cardLight = Colors.white;
static const cardDark = Color(0xFF1E1E1E);
}
// ─── Route Constants ─────────────────────────────────────────────────────────
class Routes {
static const splash = '/';
static const habits = '/habits';
static const statistics = '/statistics';
static const settings = '/settings';
static const onboarding = '/onboarding';
static const createHabit = '/create';
static const editHabit = '/edit';
static const whatsNew = '/whatsnew';
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,753 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
/// The translations for English (`en`).
class AppLocalizationsEn extends AppLocalizations {
AppLocalizationsEn([String locale = 'en']) : super(locale);
@override
String get habits => 'Habits:';
@override
String get statistics => 'Statistics';
@override
String get emptyList => 'Empty list';
@override
String get noDataAboutHabits => 'There is no data about habits.';
@override
String get topStreak => 'Top streak';
@override
String get currentStreak => 'Current streak';
@override
String get total => 'Total';
@override
String get unknown => 'Unknown';
@override
String get warning => 'Warning';
@override
String get allHabitsWillBeReplaced =>
'All habits will be replaced with habits from backup.';
@override
String get restore => 'Restore';
@override
String get cancel => 'Cancel';
@override
String get settings => 'Settings';
@override
String get theme => 'Theme';
@override
String get firstDayOfWeek => 'First day of the week';
@override
String get notifications => 'Notifications';
@override
String get notificationTime => 'Notification time';
@override
String get soundEffects => 'Sound effects';
@override
String get showMonthName => 'Show month name';
@override
String get setColors => 'Set colors';
@override
String get backup => 'Backup';
@override
String get create => 'Create';
@override
String get onboarding => 'Onboarding';
@override
String get about => 'About';
@override
String get habo => 'Habo';
@override
String get copyright => '©2023 Habo';
@override
String get termsAndConditions => 'Terms and Conditions';
@override
String get privacyPolicy => 'Privacy Policy';
@override
String get disclaimer => 'Disclaimer';
@override
String get sourceCode => 'Source code (GitHub)';
@override
String get ifYouWantToSupport => 'If you want to support Habo you can:';
@override
String get buyMeACoffee => 'Buy me a coffee';
@override
String get reset => 'Reset';
@override
String get done => 'Done';
@override
String get congratulationsReward => 'Congratulations! Your reward:';
@override
String get ohNoSanction => 'Oh no! Your sanction:';
@override
String get month => 'Month';
@override
String get week => 'Week';
@override
String get habitLoop => 'Habit loop';
@override
String get habitLoopDescription =>
'Habit Loop is a psychological model describing the process of habit formation. It consists of three components: Cue, Routine, and Reward. The Cue triggers the Routine (habitual action), which is then reinforced by the Reward, creating a loop that makes the habit more ingrained and likely to be repeated.';
@override
String get cue => 'Cue';
@override
String get cueDescription =>
'is the trigger that initiates your habit. It could be a specific time, location, feeling, or an event.';
@override
String get routine => 'Routine';
@override
String get routineDescription =>
'is the action you take in response to the cue. This is the habit itself.';
@override
String get reward => 'Reward';
@override
String get rewardDescription =>
'is the benefit or positive feeling you experience after performing the routine. It reinforces the habit.';
@override
String get editHabit => 'Edit Habit';
@override
String get createHabit => 'Create Habit';
@override
String get delete => 'Delete';
@override
String get habitTitleEmptyError => 'The habit title can not be empty.';
@override
String get save => 'Save';
@override
String get exercise => 'Exercise';
@override
String get habit => 'Habit';
@override
String get useTwoDayRule => 'Use Two day rule';
@override
String get twoDayRule => 'Two day rule';
@override
String get twoDayRuleDescription =>
'With two day rule, you can miss one day and do not lose a streak if the next day is successful.';
@override
String get advancedHabitBuilding => 'Advanced habit building';
@override
String get advancedHabitBuildingDescription =>
'This section helps you better define your habits utilizing the Habit loop. You should define cues, routines, and rewards for every habit.';
@override
String get at7AM => 'At 7:00AM';
@override
String get do50PushUps => 'Do 50 push ups';
@override
String get fifteenMinOfVideoGames => '15 min. of video games';
@override
String get showReward => 'Show reward';
@override
String get remainderOfReward =>
'The reminder of the reward after a successful routine.';
@override
String get habitContract => 'Habit contract';
@override
String get habitContractDescription =>
'While positive reinforcement is recommended, some people may opt for a habit contract. A habit contract allows you to specify a sanction that will be imposed if you miss your habit, and may involve an accountability partner who helps supervise your goals.';
@override
String get donateToCharity => 'Donate 10\$ to charity';
@override
String get sanction => 'Sanction';
@override
String get showSanction => 'Show sanction';
@override
String get remainderOfSanction =>
'The reminder of the sanction after a unsuccessful routine.';
@override
String get dan => 'Dan';
@override
String get accountabilityPartner => 'Accountability partner';
@override
String get add => 'Add';
@override
String get haboNeedsPermission =>
'Habo needs permission to send notifications to work properly.';
@override
String get allow => 'Allow';
@override
String get date => 'Date';
@override
String get check => 'Check';
@override
String get fail => 'Fail';
@override
String get skip => 'Skip';
@override
String get note => 'Note';
@override
String get yourCommentHere => 'Your note here';
@override
String get close => 'Close';
@override
String get createYourFirstHabit => 'Create your first habit.';
@override
String get modify => 'Modify';
@override
String get backupFailedError => 'ERROR: Creating backup failed.';
@override
String get restoreFailedError => 'ERROR: Restoring backup failed.';
@override
String get habitDeleted => 'Habit deleted.';
@override
String get undo => 'Undo';
@override
String get appNotifications => 'App notifications';
@override
String get appNotificationsChannel =>
'Notification channel for application notifications';
@override
String get habitNotifications => 'Habit notifications';
@override
String get habitNotificationsChannel =>
'Notification channel for habit notifications';
@override
String get doNotForgetToCheckYourHabits =>
'Do not forget to check your habits.';
@override
String themeSelect(String theme) {
String _temp0 = intl.Intl.selectLogic(theme, {
'device': 'Device',
'light': 'Light',
'dark': 'Dark',
'oled': 'OLED black',
'materialYou': 'Material You',
'other': 'Device',
});
return '$_temp0';
}
@override
String get defineYourHabits => 'Define your habits';
@override
String get defineYourHabitsDescription =>
'To better stick to your habits, you can define:';
@override
String get cueNumbered => '1. Cue';
@override
String get routineNumbered => '2. Routine';
@override
String get rewardNumbered => '3. Reward';
@override
String get logYourDays => 'Log your days';
@override
String get successful => 'Successful';
@override
String get notSoSuccessful => 'Not so successful';
@override
String get skipDoesNotAffectStreaks => 'Skip (does not affect streaks)';
@override
String get observeYourProgress => 'Observe your progress';
@override
String get trackYourProgress =>
'You can track your progress through the calendar view in every habit or on the statistics page.';
@override
String get backupCreatedSuccessfully => 'Backup created successfully!';
@override
String get backupFailed => 'Backup failed!';
@override
String get restoreCompletedSuccessfully => 'Restore completed successfully!';
@override
String get restoreFailed => 'Restore failed!';
@override
String get fileNotFound => 'File not found';
@override
String get fileTooLarge => 'File too large (max 10MB)';
@override
String get invalidBackupFile => 'Invalid backup file';
@override
String get progress => 'Progress';
@override
String get enterAmount => 'Enter amount';
@override
String get complete => 'Complete';
@override
String get saveProgress => 'Save Progress';
@override
String currentProgress(String current, String unit) {
return 'Current: $current $unit';
}
@override
String targetProgress(String target, String unit) {
return 'Target: $target $unit';
}
@override
String progressOf(String current, String target, String unit) {
return '$current / $target $unit';
}
@override
String get numericHabit => 'Progressive';
@override
String get targetValue => 'Target value';
@override
String get partialValue => 'Partial value';
@override
String get unit => 'Unit';
@override
String get habitType => 'Habit type';
@override
String get booleanHabit => 'Checkable (Yes/No)';
@override
String get slider => 'Slider';
@override
String get input => 'Input';
@override
String get numericHabitDescription =>
'Numeric habits let you track progress in increments throughout the day.';
@override
String get partialValueDescription =>
'To track progress in smaller increments';
@override
String get categories => 'Categories';
@override
String get addCategory => 'Add Category';
@override
String get editCategory => 'Edit Category';
@override
String get category => 'Category';
@override
String get noCategoriesYet => 'No categories yet';
@override
String get createFirstCategory =>
'Create your first category to organize your habits';
@override
String get pleaseEnterCategoryTitle => 'Please enter a category title';
@override
String categoryAlreadyExists(String title) {
return 'Category \"$title\" already exists';
}
@override
String categoryCreatedSuccessfully(String title) {
return 'Category \"$title\" created successfully';
}
@override
String categoryUpdatedSuccessfully(String title) {
return 'Category \"$title\" updated successfully';
}
@override
String categoryDeletedSuccessfully(String title) {
return 'Category \"$title\" deleted successfully';
}
@override
String failedToSaveCategory(String error) {
return 'Failed to save category: $error';
}
@override
String failedToDeleteCategory(String error) {
return 'Failed to delete category: $error';
}
@override
String get selectCategories => 'Select Categories';
@override
String selectedCategories(int count) {
return 'Selected Categories ($count)';
}
@override
String get allCategories => 'All Categories';
@override
String get deleteCategory => 'Delete Category';
@override
String deleteCategoryConfirmation(String title) {
return 'Are you sure you want to delete \"$title\"?\n\nThis will remove the category from all habits that use it.';
}
@override
String noHabitsInCategory(String title) {
return 'No habits in \"$title\"';
}
@override
String get createHabitForCategory =>
'Create a habit and assign it to this category';
@override
String get showCategories => 'Show Categories';
@override
String get archive => 'Archive';
@override
String get unarchive => 'Unarchive';
@override
String get archiveHabit => 'Archive habit';
@override
String get unarchiveHabit => 'Unarchive habit';
@override
String get archivedHabits => 'Archived Habits';
@override
String get noArchivedHabits => 'No archived habits';
@override
String get viewArchivedHabits => 'View archived habits';
@override
String get habitArchived => 'Habit archived';
@override
String get habitUnarchived => 'Habit unarchived';
@override
String get biometric => 'Biometric';
@override
String get biometricLockEnabled => 'Biometric lock enabled';
@override
String get biometricLockDisabled => 'Biometric lock disabled';
@override
String get authenticationError => 'Authentication error';
@override
String get biometricAuthenticationRequired =>
'Biometric authentication required';
@override
String get setupFingerprintFaceUnlock =>
'Please set up your fingerprint or face unlock in device settings';
@override
String get touchSensor => 'Touch sensor';
@override
String get biometricNotRecognized => 'Biometric not recognized, try again';
@override
String get biometricRequired => 'Biometric required';
@override
String get biometricAuthenticationSucceeded =>
'Biometric authentication succeeded';
@override
String get deviceCredentialsRequired => 'Device credentials required';
@override
String get setupDeviceCredentials =>
'Please set up device credentials in settings';
@override
String get setupTouchIdFaceId =>
'Please set up your Touch ID or Face ID in device settings';
@override
String get reenableTouchIdFaceId =>
'Please reenable your Touch ID or Face ID';
@override
String get biometricLock => 'Biometric Lock';
@override
String biometricLockDescription(String authMethod) {
return 'Secure app with $authMethod';
}
@override
String get authenticateToEnable => 'Authenticate to enable biometric lock';
@override
String get authenticateToAccess => 'Please authenticate to access Habo';
@override
String get authenticationRequired => 'Authentication Required';
@override
String authenticationFailedMessage(String authMethod) {
return 'Please authenticate to access Habo using $authMethod';
}
@override
String get tryAgain => 'Try Again';
@override
String get authenticating => 'Authenticating…';
@override
String get authenticate => 'Authenticate';
@override
String get buildingBetterHabits => 'Building Better Habits';
@override
String authenticationPrompt(String authMethod) {
return 'Please authenticate using $authMethod to access your habits';
}
@override
String get devicePinPatternPassword => 'Device PIN, Pattern, or Password';
@override
String get fingerprint => 'Fingerprint';
@override
String get iris => 'Iris';
@override
String get whatsNewTitle => 'What\'s New';
@override
String whatsNewVersion(String version) {
return 'Version $version';
}
@override
String get featureNumericTitle => 'Numeric values in habits';
@override
String get featureNumericDesc =>
'Track counts like glasses of water or pages read';
@override
String get featureDeepLinksTitle => 'URL scheme (deep links)';
@override
String get featureDeepLinksDesc =>
'Open Habo directly to screens like settings or create';
@override
String get featureCategoriesTitle => 'Categories';
@override
String get featureCategoriesDesc => 'Organize habits with category filters';
@override
String get featureArchiveTitle => 'Archive';
@override
String get featureArchiveDesc =>
'Hide habits you no longer track without deleting';
@override
String get featureMaterialYouTitle => 'Material You theme (Android)';
@override
String get featureMaterialYouDesc =>
'Dynamic colors that match your wallpaper';
@override
String get featureSoundTitle => 'New sound engine';
@override
String get featureSoundDesc => 'Adjustable volume';
@override
String get featureLockTitle => 'Lock feature';
@override
String get featureLockDesc =>
'Secure the app with Face ID / Touch ID / biometrics';
@override
String get featureIosSoundMixingTitle => 'Fixed sound mixing';
@override
String get featureIosSoundMixingDesc =>
'Habo sounds no longer interrupt your music or podcasts';
@override
String get featureHomescreenWidgetTitle => 'Homescreen widget';
@override
String get featureHomescreenWidgetDesc =>
'View your habit progress at a glance from your home screen (experimental)';
@override
String get featureLongpressCheckTitle => 'Longpress check';
@override
String get featureLongpressCheckDesc =>
'Longpress on habit buttons to quickly change status';
@override
String get haboSyncComingSoon => 'Coming Soon';
@override
String get haboSyncDescription =>
'Sync your habits across all your devices with Habo\'s end-to-end encrypted cloud service.';
@override
String get haboSyncLearnMore => 'Learn more at habo.space/sync';
@override
String get habitsToday => 'Habits today';
@override
String get or => 'or';
@override
String get oneTapCheck => 'Single tap to check';
@override
String get tapCheckLongPressMenu => 'Tap to check, long press for menu';
@override
String get categoryName => 'Category name';
@override
String get createCategory => 'Create category';
@override
String get all => 'All';
@override
String get selectIcon => 'Pick an icon';
@override
String get searchIcons => 'Search';
@override
String get habitArchivedSuccess => 'Habit archived';
@override
String get habitUnarchivedSuccess => 'Habit unarchived';
}

282
lib/generated/l10n.dart Normal file
View File

@@ -0,0 +1,282 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:intl/intl.dart' as intl;
/// Stub localization class for testing.
/// The actual implementation is generated by flutter gen-l10n.
class S {
S([String locale = 'en']) : localeName = locale;
final String localeName;
static S? _current;
static S get current {
if (_current == null) {
_current = S();
}
return _current!;
}
static S of(BuildContext context) {
return current;
}
static Future<S> load(Locale locale) {
_current = S(locale.toString());
return SynchronousFuture<S>(current);
}
static const LocalizationsDelegate<S> delegate = _SDelegate();
String get habits => 'Habits:';
String get statistics => 'Statistics';
String get emptyList => 'Empty list';
String get noDataAboutHabits => 'There is no data about habits.';
String get topStreak => 'Top streak';
String get currentStreak => 'Current streak';
String get total => 'Total';
String get unknown => 'Unknown';
String get warning => 'Warning';
String get allHabitsWillBeReplaced => 'All habits will be replaced with habits from backup.';
String get restore => 'Restore';
String get cancel => 'Cancel';
String get settings => 'Settings';
String get theme => 'Theme';
String get firstDayOfWeek => 'First day of the week';
String get notifications => 'Notifications';
String get notificationTime => 'Notification time';
String get soundEffects => 'Sound effects';
String get showMonthName => 'Show month name';
String get setColors => 'Set colors';
String get backup => 'Backup';
String get create => 'Create';
String get onboarding => 'Onboarding';
String get about => 'About';
String get habo => 'Habo';
String get copyright => '©2023 Habo';
String get termsAndConditions => 'Terms and Conditions';
String get privacyPolicy => 'Privacy Policy';
String get disclaimer => 'Disclaimer';
String get sourceCode => 'Source code (GitHub)';
String get ifYouWantToSupport => 'If you want to support Habo you can:';
String get buyMeACoffee => 'Buy me a coffee';
String get reset => 'Reset';
String get done => 'Done';
String get congratulationsReward => 'Congratulations! Your reward:';
String get ohNoSanction => 'Oh no! Your sanction:';
String get month => 'Month';
String get week => 'Week';
String get habitLoop => 'Habit loop';
String get habitLoopDescription => 'Habit Loop is a psychological model describing the process of habit formation.';
String get cue => 'Cue';
String get cueDescription => 'is the trigger that initiates your habit.';
String get routine => 'Routine';
String get routineDescription => 'is the action you take in response to the cue.';
String get reward => 'Reward';
String get rewardDescription => 'is the benefit or positive feeling you experience after performing the routine.';
String get editHabit => 'Edit Habit';
String get createHabit => 'Create Habit';
String get delete => 'Delete';
String get habitTitleEmptyError => 'The habit title can not be empty.';
String get save => 'Save';
String get exercise => 'Exercise';
String get habit => 'Habit';
String get useTwoDayRule => 'Use Two day rule';
String get twoDayRule => 'Two day rule';
String get twoDayRuleDescription => 'With two day rule, you can miss one day and do not lose a streak if the next day is successful.';
String get advancedHabitBuilding => 'Advanced habit building';
String get advancedHabitBuildingDescription => 'This section helps you better define your habits utilizing the Habit loop.';
String get at7AM => 'At 7:00AM';
String get do50PushUps => 'Do 50 push ups';
String get fifteenMinOfVideoGames => '15 min. of video games';
String get showReward => 'Show reward';
String get remainderOfReward => 'The reminder of the reward after a successful routine.';
String get habitContract => 'Habit contract';
String get habitContractDescription => 'While positive reinforcement is recommended, some people may opt for a habit contract.';
String get donateToCharity => 'Donate 10\$ to charity';
String get sanction => 'Sanction';
String get showSanction => 'Show sanction';
String get remainderOfSanction => 'The reminder of the sanction after a unsuccessful routine.';
String get dan => 'Dan';
String get accountabilityPartner => 'Accountability partner';
String get add => 'Add';
String get haboNeedsPermission => 'Habo needs permission to send notifications to work properly.';
String get allow => 'Allow';
String get date => 'Date';
String get check => 'Check';
String get fail => 'Fail';
String get skip => 'Skip';
String get note => 'Note';
String get yourCommentHere => 'Your note here';
String get close => 'Close';
String get createYourFirstHabit => 'Create your first habit.';
String get modify => 'Modify';
String get backupFailedError => 'ERROR: Creating backup failed.';
String get restoreFailedError => 'ERROR: Restoring backup failed.';
String get habitDeleted => 'Habit deleted.';
String get undo => 'Undo';
String get appNotifications => 'App notifications';
String get appNotificationsChannel => 'Notification channel for application notifications';
String get habitNotifications => 'Habit notifications';
String get habitNotificationsChannel => 'Notification channel for habit notifications';
String get doNotForgetToCheckYourHabits => 'Do not forget to check your habits.';
String themeSelect(String theme) => theme;
String get defineYourHabits => 'Define your habits';
String get defineYourHabitsDescription => 'To better stick to your habits, you can define:';
String get cueNumbered => '1. Cue';
String get routineNumbered => '2. Routine';
String get rewardNumbered => '3. Reward';
String get logYourDays => 'Log your days';
String get successful => 'Successful';
String get notSoSuccessful => 'Not so successful';
String get skipDoesNotAffectStreaks => 'Skip (does not affect streaks)';
String get observeYourProgress => 'Observe your progress';
String get trackYourProgress => 'You can track your progress through the calendar view in every habit or on the statistics page.';
String get backupCreatedSuccessfully => 'Backup created successfully!';
String get backupFailed => 'Backup failed!';
String get restoreCompletedSuccessfully => 'Restore completed successfully!';
String get restoreFailed => 'Restore failed!';
String get fileNotFound => 'File not found';
String get fileTooLarge => 'File too large (max 10MB)';
String get invalidBackupFile => 'Invalid backup file';
String get progress => 'Progress';
String get enterAmount => 'Enter amount';
String get complete => 'Complete';
String get saveProgress => 'Save Progress';
String currentProgress(String current, String unit) => 'Current: $current $unit';
String targetProgress(String target, String unit) => 'Target: $target $unit';
String progressOf(String current, String target, String unit) => '$current / $target $unit';
String get numericHabit => 'Progressive';
String get targetValue => 'Target value';
String get partialValue => 'Partial value';
String get unit => 'Unit';
String get habitType => 'Habit type';
String get booleanHabit => 'Checkable (Yes/No)';
String get slider => 'Slider';
String get input => 'Input';
String get numericHabitDescription => 'Numeric habits let you track progress in increments throughout the day.';
String get partialValueDescription => 'To track progress in smaller increments';
String get categories => 'Categories';
String get addCategory => 'Add Category';
String get editCategory => 'Edit Category';
String get category => 'Category';
String get noCategoriesYet => 'No categories yet';
String get createFirstCategory => 'Create your first category to organize your habits';
String get pleaseEnterCategoryTitle => 'Please enter a category title';
String categoryAlreadyExists(String title) => 'Category "$title" already exists';
String categoryCreatedSuccessfully(String title) => 'Category "$title" created successfully';
String categoryUpdatedSuccessfully(String title) => 'Category "$title" updated successfully';
String categoryDeletedSuccessfully(String title) => 'Category "$title" deleted successfully';
String failedToSaveCategory(String error) => 'Failed to save category: $error';
String failedToDeleteCategory(String error) => 'Failed to delete category: $error';
String get selectCategories => 'Select Categories';
String selectedCategories(int count) => 'Selected Categories ($count)';
String get allCategories => 'All Categories';
String get deleteCategory => 'Delete Category';
String deleteCategoryConfirmation(String title) => 'Are you sure you want to delete "$title"?';
String noHabitsInCategory(String title) => 'No habits in "$title"';
String get createHabitForCategory => 'Create a habit and assign it to this category';
String get showCategories => 'Show Categories';
String get archive => 'Archive';
String get unarchive => 'Unarchive';
String get archiveHabit => 'Archive habit';
String get unarchiveHabit => 'Unarchive habit';
String get archivedHabits => 'Archived Habits';
String get noArchivedHabits => 'No archived habits';
String get viewArchivedHabits => 'View archived habits';
String get habitArchived => 'Habit archived';
String get habitUnarchived => 'Habit unarchived';
String get biometric => 'Biometric';
String get biometricLockEnabled => 'Biometric lock enabled';
String get biometricLockDisabled => 'Biometric lock disabled';
String get authenticationError => 'Authentication error';
String get biometricAuthenticationRequired => 'Biometric authentication required';
String get setupFingerprintFaceUnlock => 'Please set up your fingerprint or face unlock in device settings';
String get touchSensor => 'Touch sensor';
String get biometricNotRecognized => 'Biometric not recognized, try again';
String get biometricRequired => 'Biometric required';
String get biometricAuthenticationSucceeded => 'Biometric authentication succeeded';
String get deviceCredentialsRequired => 'Device credentials required';
String get setupDeviceCredentials => 'Please set up device credentials in settings';
String get setupTouchIdFaceId => 'Please set up your Touch ID or Face ID in device settings';
String get reenableTouchIdFaceId => 'Please reenable your Touch ID or Face ID';
String get biometricLock => 'Biometric Lock';
String biometricLockDescription(String authMethod) => 'Secure app with $authMethod';
String get authenticateToEnable => 'Authenticate to enable biometric lock';
String get authenticateToAccess => 'Please authenticate to access Habo';
String get authenticationRequired => 'Authentication Required';
String authenticationFailedMessage(String authMethod) => 'Please authenticate to access Habo using $authMethod';
String get tryAgain => 'Try Again';
String get authenticating => 'Authenticating…';
String get authenticate => 'Authenticate';
String get buildingBetterHabits => 'Building Better Habits';
String authenticationPrompt(String authMethod) => 'Please authenticate using $authMethod to access your habits';
String get devicePinPatternPassword => 'Device PIN, Pattern, or Password';
String get fingerprint => 'Fingerprint';
String get iris => 'Iris';
String get whatsNewTitle => "What's New";
String whatsNewVersion(String version) => 'Version $version';
String get featureNumericTitle => 'Numeric values in habits';
String get featureNumericDesc => 'Track counts like glasses of water or pages read';
String get featureDeepLinksTitle => 'URL scheme (deep links)';
String get featureDeepLinksDesc => 'Open Habo directly to screens like settings or create';
String get featureCategoriesTitle => 'Categories';
String get featureCategoriesDesc => 'Organize habits with category filters';
String get featureArchiveTitle => 'Archive';
String get featureArchiveDesc => 'Hide habits you no longer track without deleting';
String get featureMaterialYouTitle => 'Material You theme (Android)';
String get featureMaterialYouDesc => 'Dynamic colors that match your wallpaper';
String get featureSoundTitle => 'New sound engine';
String get featureSoundDesc => 'Adjustable volume';
String get featureLockTitle => 'Lock feature';
String get featureLockDesc => 'Secure the app with Face ID / Touch ID / biometrics';
String get featureIosSoundMixingTitle => 'Fixed sound mixing';
String get featureIosSoundMixingDesc => 'Habo sounds no longer interrupt your music or podcasts';
String get featureHomescreenWidgetTitle => 'Homescreen widget';
String get featureHomescreenWidgetDesc => 'View your habit progress at a glance from your home screen (experimental)';
String get featureLongpressCheckTitle => 'Longpress check';
String get featureLongpressCheckDesc => 'Longpress on habit buttons to quickly change status';
String get haboSyncComingSoon => 'Coming Soon';
String get haboSyncDescription => "Sync your habits across all your devices with Habo's end-to-end encrypted cloud service.";
String get haboSyncLearnMore => 'Learn more at habo.space/sync';
String get habitsToday => 'Habits today';
String get or => 'or';
String get oneTapCheck => 'Single tap to check';
String get tapCheckLongPressMenu => 'Tap to check, long press for menu';
String get categoryName => 'Category name';
String get createCategory => 'Create category';
String get all => 'All';
String get selectIcon => 'Pick an icon';
String get searchIcons => 'Search';
String get habitArchivedSuccess => 'Habit archived';
String get habitUnarchivedSuccess => 'Habit unarchived';
}
class _SDelegate extends LocalizationsDelegate<S> {
const _SDelegate();
@override
Future<S> load(Locale locale) => S.load(locale);
@override
bool isSupported(Locale locale) => true;
@override
bool shouldReload(_SDelegate old) => false;
}
class AppLocalizations {
static const LocalizationsDelegate<S> delegate = S.delegate;
static S of(BuildContext context) => S.of(context);
static List<LocalizationsDelegate<dynamic>> localizationsDelegates = [
S.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
];
static List<Locale> supportedLocales = [
const Locale('en'),
];
}

View File

@@ -0,0 +1,19 @@
import 'package:flutter/material.dart';
class CalendarColumn extends StatelessWidget {
final List<dynamic>? habits;
final List<dynamic>? categories;
final Function(int, int)? onReorder;
const CalendarColumn({
super.key,
this.habits,
this.categories,
this.onReorder,
});
@override
Widget build(BuildContext context) {
return const SizedBox.shrink(); // Stub
}
}

View File

@@ -0,0 +1,343 @@
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);
}
}

View File

@@ -0,0 +1,13 @@
import 'package:flutter/material.dart';
class EditHabitScreen extends StatelessWidget {
const EditHabitScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Edit Habit')),
body: const Center(child: Text('Edit Habit')),
);
}
}

53
lib/habits/habit.dart Normal file
View File

@@ -0,0 +1,53 @@
import 'package:flutter/material.dart';
import 'package:habo/model/habit_data.dart';
import 'package:habo/model/category.dart';
/// Habit is a StatefulWidget wrapper around HabitData.
/// It manages the UI state for a single habit card.
class Habit extends StatefulWidget {
final HabitData habitData;
Habit({
super.key,
required this.habitData,
});
set setId(int id) {
habitData.id = id;
}
factory Habit.fromJson(Map<String, dynamic> json) {
return Habit(
habitData: HabitData.fromJson(json),
);
}
Map<String, dynamic> toJson() {
final data = habitData.toJson();
data['categories'] = habitData.categories.map((c) => c.toJson()).toList();
return data;
}
@override
State<Habit> createState() => _HabitState();
}
class _HabitState extends State<Habit> {
DateTime? _selectedDate;
bool _isExpanded = false;
DateTime get selectedDate => _selectedDate ?? DateTime.now();
set selectedDate(DateTime date) {
_selectedDate = date;
}
bool get isExpanded => _isExpanded;
set isExpanded(bool value) {
_isExpanded = value;
}
@override
Widget build(BuildContext context) {
return Container();
}
}

View File

@@ -0,0 +1,441 @@
import 'dart:async';
import 'dart:collection';
import 'package:flutter/material.dart';
import 'package:habo/constants.dart';
import 'package:habo/habits/habit.dart';
import 'package:habo/model/habit_data.dart';
import 'package:habo/model/category.dart';
import 'package:habo/repositories/habit_repository.dart';
import 'package:habo/repositories/event_repository.dart';
import 'package:habo/repositories/category_repository.dart';
import 'package:habo/services/backup_service.dart';
import 'package:habo/services/notification_service.dart';
import 'package:habo/services/ui_feedback_service.dart';
class HabitsManager extends ChangeNotifier {
final HabitRepository _habitRepository;
final EventRepository _eventRepository;
final CategoryRepository _categoryRepository;
final BackupService? _backupService;
final NotificationService? _notificationService;
final UIFeedbackService? _uiFeedbackService;
final List<Habit> _allHabits = [];
final List<Habit> _toDelete = [];
final List<Category> _categories = [];
List<Habit> get allHabits => _allHabits;
List<Habit> get toDelete => _toDelete;
List<Category> get categories => _categories;
List<Habit> get activeHabits =>
_allHabits.where((h) => !h.habitData.archived).toList();
List<Habit> get archivedHabits =>
_allHabits.where((h) => h.habitData.archived).toList();
HabitsManager({
required HabitRepository habitRepository,
required EventRepository eventRepository,
required CategoryRepository categoryRepository,
BackupService? backupService,
NotificationService? notificationService,
UIFeedbackService? uiFeedbackService,
}) : _habitRepository = habitRepository,
_eventRepository = eventRepository,
_categoryRepository = categoryRepository,
_backupService = backupService,
_notificationService = notificationService,
_uiFeedbackService = uiFeedbackService;
// ─── Initialization ────────────────────────────────────────────────────────
Future<void> initModel() async {
await loadHabits();
await loadCategories();
}
Future<void> loadHabits() async {
_allHabits.clear();
final habits = await _habitRepository.getAllHabits();
for (final habit in habits) {
try {
final events = await _eventRepository.getEventsMapForHabit(
habit.habitData.id ?? 0,
);
habit.habitData.events = events;
} catch (_) {
// If event loading fails, keep empty events
}
_allHabits.add(habit);
}
_allHabits.sort((a, b) => a.habitData.position.compareTo(b.habitData.position));
notifyListeners();
}
Future<void> loadCategories() async {
_categories.clear();
try {
final cats = await _categoryRepository.getAllCategories();
_categories.addAll(cats);
} catch (_) {
// If category loading fails, keep empty
}
notifyListeners();
}
// ─── CRUD Operations ───────────────────────────────────────────────────────
void addHabit(
String title,
bool twoDayRule,
String cue,
String routine,
String reward,
bool showReward,
bool advanced,
bool notification,
TimeOfDay notTime,
String sanction,
bool showSanction,
String accountant, {
HabitType habitType = HabitType.boolean,
double targetValue = 100.0,
double partialValue = 10.0,
String unit = '',
}) {
final habitData = HabitData(
position: _allHabits.length,
title: title,
twoDayRule: twoDayRule,
cue: cue,
routine: routine,
reward: reward,
showReward: showReward,
advanced: advanced,
notification: notification,
notTime: notTime,
sanction: sanction,
showSanction: showSanction,
accountant: accountant,
habitType: habitType,
targetValue: targetValue,
partialValue: partialValue,
unit: unit,
events: SplayTreeMap<DateTime, List>(),
);
final habit = Habit(habitData: habitData);
_habitRepository.createHabit(habit);
_allHabits.add(habit);
_notificationService?.resetNotifications(activeHabits);
notifyListeners();
}
void editHabit(HabitData data) {
final index = _allHabits.indexWhere((h) => h.habitData.id == data.id);
if (index != -1) {
_allHabits[index].habitData
..title = data.title
..twoDayRule = data.twoDayRule
..cue = data.cue
..routine = data.routine
..reward = data.reward
..showReward = data.showReward
..advanced = data.advanced
..notification = data.notification
..notTime = data.notTime
..sanction = data.sanction
..showSanction = data.showSanction
..accountant = data.accountant
..habitType = data.habitType
..targetValue = data.targetValue
..partialValue = data.partialValue
..unit = data.unit
..archived = data.archived;
_habitRepository.updateHabit(_allHabits[index]);
_notificationService?.resetNotifications(activeHabits);
notifyListeners();
}
}
void deleteHabit(int id) {
final habit = findHabitById(id);
if (habit != null) {
_toDelete.add(habit);
_allHabits.remove(habit);
_habitRepository.deleteHabit(id);
_notificationService?.removeNotifications(id);
_uiFeedbackService?.showMessageWithAction(
message: 'Habit deleted.',
actionLabel: 'Undo',
onActionPressed: () => undoDelete(),
backgroundColor: Colors.red,
);
updateOrder();
notifyListeners();
}
}
void undoDelete() {
if (_toDelete.isNotEmpty) {
final habit = _toDelete.removeLast();
_allHabits.add(habit);
updateOrder();
notifyListeners();
}
}
// ─── Archive Operations ────────────────────────────────────────────────────
void archiveHabit(int id) {
final habit = findHabitById(id);
if (habit != null) {
habit.habitData.archived = true;
_habitRepository.updateHabit(habit);
_notificationService?.disableHabitNotification(id);
_uiFeedbackService?.showMessageWithAction(
message: 'Habit archived',
actionLabel: 'Undo',
onActionPressed: () => unarchiveHabit(id),
backgroundColor: Colors.orange,
);
notifyListeners();
}
}
void unarchiveHabit(int id) {
final habit = findHabitById(id);
if (habit != null) {
habit.habitData.archived = false;
_habitRepository.updateHabit(habit);
if (habit.habitData.notification) {
_notificationService?.setHabitNotification(
id,
habit.habitData.notTime,
'Habo',
habit.habitData.title,
);
}
_uiFeedbackService?.showSuccess('Habit unarchived');
notifyListeners();
}
}
// ─── Reorder ───────────────────────────────────────────────────────────────
void reorderList(int oldIndex, int newIndex) {
if (oldIndex < newIndex) newIndex--;
final item = _allHabits.removeAt(oldIndex);
_allHabits.insert(newIndex, item);
updateOrder();
_habitRepository.updateHabitsOrder(_allHabits);
notifyListeners();
}
void updateOrder() {
for (int i = 0; i < _allHabits.length; i++) {
_allHabits[i].habitData.position = i;
}
// In-memory position update only - persistence handled separately
}
// ─── Event Operations ──────────────────────────────────────────────────────
void addEvent(int id, DateTime date, List event) {
final key = DateTime(date.year, date.month, date.day);
// Update in-memory if habit exists
final habit = findHabitById(id);
if (habit != null) {
habit.habitData.events[key] = event;
_updateLastStreak(habit.habitData);
}
// Always persist to repository
try {
_eventRepository.insertEvent(id, key, event);
} catch (_) {}
notifyListeners();
}
void deleteEvent(int id, DateTime date) {
final key = DateTime(date.year, date.month, date.day);
// Update in-memory if habit exists
final habit = findHabitById(id);
if (habit != null) {
habit.habitData.events.remove(key);
_updateLastStreak(habit.habitData);
}
// Always persist to repository
try {
_eventRepository.deleteEvent(id, key);
} catch (_) {}
notifyListeners();
}
void _updateLastStreak(HabitData data) {
final events = data.events;
if (events.isEmpty) {
data.streak = 0;
data.streakVisible = false;
data.orangeStreak = false;
return;
}
if (data.twoDayRule) {
_updateLastStreakTwoDay(data);
} else {
_updateLastStreakNormal(data);
}
}
void _updateLastStreakNormal(HabitData data) {
final events = data.events;
if (events.isEmpty) {
data.streak = 0;
data.streakVisible = false;
data.orangeStreak = false;
return;
}
int inStreak = 0;
final dates = events.keys.toList().reversed.toList();
for (int i = 0; i < dates.length; i++) {
final date = dates[i];
final event = events[date]!;
final dayType = event[0] is DayType ? event[0] as DayType : DayType.values[event[0] as int];
if (dayType == DayType.clear) continue;
if (i > 0) {
final prevDate = dates[i - 1];
final diff = prevDate.difference(date).inDays;
if (diff > 1) break;
}
if (dayType == DayType.check) {
inStreak++;
} else if (dayType == DayType.progress && event.length >= 4) {
final progressValue = event[2] as double? ?? 0.0;
final target = event[3] as double? ?? data.targetValue;
if (progressValue >= target) inStreak++;
} else if (dayType == DayType.fail || dayType == DayType.skip) {
break;
}
}
data.streak = inStreak;
data.streakVisible = inStreak >= 2;
data.orangeStreak = false;
}
void _updateLastStreakTwoDay(HabitData data) {
final events = data.events;
if (events.isEmpty) {
data.streak = 0;
data.streakVisible = false;
data.orangeStreak = false;
return;
}
int inStreak = 0;
bool usingTwoDayRule = false;
final dates = events.keys.toList().reversed.toList();
for (int i = 0; i < dates.length; i++) {
final date = dates[i];
final event = events[date]!;
final dayType = event[0] is DayType ? event[0] as DayType : DayType.values[event[0] as int];
if (dayType == DayType.clear) continue;
if (i > 0) {
final prevDate = dates[i - 1];
final diff = prevDate.difference(date).inDays;
if (diff > 1) break;
}
if (dayType == DayType.check) {
inStreak++;
usingTwoDayRule = false;
} else if (dayType == DayType.progress && event.length >= 4) {
final progressValue = event[2] as double? ?? 0.0;
final target = event[3] as double? ?? data.targetValue;
if (progressValue >= target) {
inStreak++;
usingTwoDayRule = false;
}
} else if (dayType == DayType.fail) {
if (usingTwoDayRule) {
break;
}
usingTwoDayRule = true;
} else if (dayType == DayType.skip) {
if (usingTwoDayRule) break;
// Skip doesn't affect streak
}
}
data.streak = inStreak;
data.streakVisible = inStreak >= 2;
data.orangeStreak = usingTwoDayRule;
}
// ─── Utility ───────────────────────────────────────────────────────────────
Habit? findHabitById(int id) {
try {
return _allHabits.firstWhere((h) => h.habitData.id == id);
} catch (_) {
return null;
}
}
String getNameOfHabit(int id) {
final habit = findHabitById(id);
return habit?.habitData.title ?? '';
}
// ─── Backup & Widget ───────────────────────────────────────────────────────
Future<void> createBackup() async {
await _backupService?.createDatabaseBackup();
}
Future<void> loadBackup(String path) async {
await _backupService?.loadBackup(path);
await loadHabits();
}
void resetNotifications([List<dynamic>? habits]) {
_notificationService?.resetNotifications(habits ?? activeHabits);
}
void updateHomeWidget() {
// Stub
}
// ─── Category ──────────────────────────────────────────────────────────────
Future<void> addCategory(Category category) async {
await _categoryRepository.createCategory(category);
await loadCategories();
}
Future<void> updateCategory(Category category) async {
await _categoryRepository.updateCategory(category);
await loadCategories();
}
Future<void> deleteCategory(int id) async {
await _categoryRepository.deleteCategory(id);
await loadCategories();
}
}

View File

@@ -0,0 +1,335 @@
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;
}

19
lib/helpers.dart Normal file
View File

@@ -0,0 +1,19 @@
String dayOfWeek(int day) {
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
if (day >= 1 && day <= 7) return days[day - 1];
return '';
}
String monthName(int month) {
const months = ['January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'];
if (month >= 1 && month <= 12) return months[month - 1];
return '';
}
String formatTimeOfDay(int hour, int minute) {
final period = hour >= 12 ? 'PM' : 'AM';
final h = hour > 12 ? hour - 12 : (hour == 0 ? 12 : hour);
final m = minute.toString().padLeft(2, '0');
return '$h:$m $period';
}

264
lib/l10n/intl_en.arb Normal file
View File

@@ -0,0 +1,264 @@
{
"@@locale": "en",
"habits": "Habits:",
"statistics": "Statistics",
"emptyList": "Empty list",
"noDataAboutHabits": "There is no data about habits.",
"topStreak": "Top streak",
"currentStreak": "Current streak",
"total": "Total",
"unknown": "Unknown",
"warning": "Warning",
"allHabitsWillBeReplaced": "All habits will be replaced with habits from backup.",
"restore": "Restore",
"cancel": "Cancel",
"settings": "Settings",
"theme": "Theme",
"firstDayOfWeek": "First day of the week",
"notifications": "Notifications",
"notificationTime": "Notification time",
"soundEffects": "Sound effects",
"showMonthName": "Show month name",
"setColors": "Set colors",
"backup": "Backup",
"create": "Create",
"onboarding": "Onboarding",
"about": "About",
"habo": "Habo",
"copyright": "©2023 Habo",
"termsAndConditions": "Terms and Conditions",
"privacyPolicy": "Privacy Policy",
"disclaimer": "Disclaimer",
"sourceCode": "Source code (GitHub)",
"ifYouWantToSupport": "If you want to support Habo you can:",
"buyMeACoffee": "Buy me a coffee",
"reset": "Reset",
"done": "Done",
"congratulationsReward": "Congratulations! Your reward:",
"ohNoSanction": "Oh no! Your sanction:",
"month": "Month",
"week": "Week",
"habitLoop": "Habit loop",
"habitLoopDescription": "Habit Loop is a psychological model describing the process of habit formation. It consists of three components: Cue, Routine, and Reward. The Cue triggers the Routine (habitual action), which is then reinforced by the Reward, creating a loop that makes the habit more ingrained and likely to be repeated.",
"cue": "Cue",
"cueDescription": "is the trigger that initiates your habit. It could be a specific time, location, feeling, or an event.",
"routine": "Routine",
"routineDescription": "is the action you take in response to the cue. This is the habit itself.",
"reward": "Reward",
"rewardDescription": "is the benefit or positive feeling you experience after performing the routine. It reinforces the habit.",
"editHabit": "Edit Habit",
"createHabit": "Create Habit",
"delete": "Delete",
"habitTitleEmptyError": "The habit title can not be empty.",
"save": "Save",
"exercise": "Exercise",
"habit": "Habit",
"useTwoDayRule": "Use Two day rule",
"twoDayRule": "Two day rule",
"twoDayRuleDescription": "With two day rule, you can miss one day and do not lose a streak if the next day is successful.",
"advancedHabitBuilding": "Advanced habit building",
"advancedHabitBuildingDescription": "This section helps you better define your habits utilizing the Habit loop. You should define cues, routines, and rewards for every habit.",
"at7AM": "At 7:00AM",
"do50PushUps": "Do 50 push ups",
"fifteenMinOfVideoGames": "15 min. of video games",
"showReward": "Show reward",
"remainderOfReward": "The reminder of the reward after a successful routine.",
"habitContract": "Habit contract",
"habitContractDescription": "While positive reinforcement is recommended, some people may opt for a habit contract. A habit contract allows you to specify a sanction that will be imposed if you miss your habit, and may involve an accountability partner who helps supervise your goals.",
"donateToCharity": "Donate 10$ to charity",
"sanction": "Sanction",
"showSanction": "Show sanction",
"remainderOfSanction": "The reminder of the sanction after a unsuccessful routine.",
"dan": "Dan",
"accountabilityPartner": "Accountability partner",
"add": "Add",
"haboNeedsPermission": "Habo needs permission to send notifications to work properly.",
"allow": "Allow",
"date": "Date",
"check": "Check",
"fail": "Fail",
"skip": "Skip",
"note": "Note",
"yourCommentHere": "Your note here",
"close": "Close",
"createYourFirstHabit": "Create your first habit.",
"modify": "Modify",
"backupFailedError": "ERROR: Creating backup failed.",
"restoreFailedError": "ERROR: Restoring backup failed.",
"habitDeleted": "Habit deleted.",
"undo": "Undo",
"appNotifications": "App notifications",
"appNotificationsChannel": "Notification channel for application notifications",
"habitNotifications": "Habit notifications",
"habitNotificationsChannel": "Notification channel for habit notifications",
"doNotForgetToCheckYourHabits": "Do not forget to check your habits.",
"themeSelect": "{theme, select, device {Device} light {Light} dark {Dark} oled {OLED black} materialYou {Material You} other{Device}}",
"@themeSelect": {
"placeholders": {
"theme": {
"type": "String"
}
}
},
"defineYourHabits": "Define your habits",
"defineYourHabitsDescription": "To better stick to your habits, you can define:",
"cueNumbered": "1. Cue",
"routineNumbered": "2. Routine",
"rewardNumbered": "3. Reward",
"logYourDays": "Log your days",
"successful": "Successful",
"notSoSuccessful": "Not so successful",
"skipDoesNotAffectStreaks": "Skip (does not affect streaks)",
"observeYourProgress": "Observe your progress",
"trackYourProgress": "You can track your progress through the calendar view in every habit or on the statistics page.",
"backupCreatedSuccessfully": "Backup created successfully!",
"backupFailed": "Backup failed!",
"restoreCompletedSuccessfully": "Restore completed successfully!",
"restoreFailed": "Restore failed!",
"fileNotFound": "File not found",
"fileTooLarge": "File too large (max 10MB)",
"invalidBackupFile": "Invalid backup file",
"progress": "Progress",
"enterAmount": "Enter amount",
"complete": "Complete",
"saveProgress": "Save Progress",
"currentProgress": "Current: {current} {unit}",
"@currentProgress": {
"placeholders": {
"current": { "type": "String" },
"unit": { "type": "String" }
}
},
"targetProgress": "Target: {target} {unit}",
"@targetProgress": {
"placeholders": {
"target": { "type": "String" },
"unit": { "type": "String" }
}
},
"progressOf": "{current} / {target} {unit}",
"@progressOf": {
"placeholders": {
"current": { "type": "String" },
"target": { "type": "String" },
"unit": { "type": "String" }
}
},
"numericHabit": "Progressive",
"targetValue": "Target value",
"partialValue": "Partial value",
"unit": "Unit",
"habitType": "Habit type",
"booleanHabit": "Checkable (Yes/No)",
"slider": "Slider",
"input": "Input",
"numericHabitDescription": "Numeric habits let you track progress in increments throughout the day.",
"partialValueDescription": "To track progress in smaller increments",
"categories": "Categories",
"addCategory": "Add Category",
"editCategory": "Edit Category",
"category": "Category",
"noCategoriesYet": "No categories yet",
"createFirstCategory": "Create your first category to organize your habits",
"pleaseEnterCategoryTitle": "Please enter a category title",
"categoryAlreadyExists": "Category \"{title}\" already exists",
"@categoryAlreadyExists": { "placeholders": { "title": { "type": "String" } } },
"categoryCreatedSuccessfully": "Category \"{title}\" created successfully",
"@categoryCreatedSuccessfully": { "placeholders": { "title": { "type": "String" } } },
"categoryUpdatedSuccessfully": "Category \"{title}\" updated successfully",
"@categoryUpdatedSuccessfully": { "placeholders": { "title": { "type": "String" } } },
"categoryDeletedSuccessfully": "Category \"{title}\" deleted successfully",
"@categoryDeletedSuccessfully": { "placeholders": { "title": { "type": "String" } } },
"failedToSaveCategory": "Failed to save category: {error}",
"@failedToSaveCategory": { "placeholders": { "error": { "type": "String" } } },
"failedToDeleteCategory": "Failed to delete category: {error}",
"@failedToDeleteCategory": { "placeholders": { "error": { "type": "String" } } },
"selectCategories": "Select Categories",
"selectedCategories": "Selected Categories ({count})",
"@selectedCategories": { "placeholders": { "count": { "type": "int" } } },
"allCategories": "All Categories",
"deleteCategory": "Delete Category",
"deleteCategoryConfirmation": "Are you sure you want to delete \"{title}\"?\n\nThis will remove the category from all habits that use it.",
"@deleteCategoryConfirmation": { "placeholders": { "title": { "type": "String" } } },
"noHabitsInCategory": "No habits in \"{title}\"",
"@noHabitsInCategory": { "placeholders": { "title": { "type": "String" } } },
"createHabitForCategory": "Create a habit and assign it to this category",
"showCategories": "Show Categories",
"archive": "Archive",
"unarchive": "Unarchive",
"archiveHabit": "Archive habit",
"unarchiveHabit": "Unarchive habit",
"archivedHabits": "Archived Habits",
"noArchivedHabits": "No archived habits",
"viewArchivedHabits": "View archived habits",
"habitArchived": "Habit archived",
"habitUnarchived": "Habit unarchived",
"biometric": "Biometric",
"biometricLockEnabled": "Biometric lock enabled",
"biometricLockDisabled": "Biometric lock disabled",
"authenticationError": "Authentication error",
"biometricAuthenticationRequired": "Biometric authentication required",
"setupFingerprintFaceUnlock": "Please set up your fingerprint or face unlock in device settings",
"touchSensor": "Touch sensor",
"biometricNotRecognized": "Biometric not recognized, try again",
"biometricRequired": "Biometric required",
"biometricAuthenticationSucceeded": "Biometric authentication succeeded",
"deviceCredentialsRequired": "Device credentials required",
"setupDeviceCredentials": "Please set up device credentials in settings",
"setupTouchIdFaceId": "Please set up your Touch ID or Face ID in device settings",
"reenableTouchIdFaceId": "Please reenable your Touch ID or Face ID",
"biometricLock": "Biometric Lock",
"biometricLockDescription": "Secure app with {authMethod}",
"@biometricLockDescription": { "placeholders": { "authMethod": { "type": "String" } } },
"authenticateToEnable": "Authenticate to enable biometric lock",
"authenticateToAccess": "Please authenticate to access Habo",
"authenticationRequired": "Authentication Required",
"authenticationFailedMessage": "Please authenticate to access Habo using {authMethod}",
"@authenticationFailedMessage": { "placeholders": { "authMethod": { "type": "String" } } },
"tryAgain": "Try Again",
"authenticating": "Authenticating…",
"authenticate": "Authenticate",
"buildingBetterHabits": "Building Better Habits",
"authenticationPrompt": "Please authenticate using {authMethod} to access your habits",
"@authenticationPrompt": { "placeholders": { "authMethod": { "type": "String" } } },
"devicePinPatternPassword": "Device PIN, Pattern, or Password",
"fingerprint": "Fingerprint",
"iris": "Iris",
"whatsNewTitle": "What's New",
"whatsNewVersion": "Version {version}",
"@whatsNewVersion": { "placeholders": { "version": { "type": "String" } } },
"featureNumericTitle": "Numeric values in habits",
"featureNumericDesc": "Track counts like glasses of water or pages read",
"featureDeepLinksTitle": "URL scheme (deep links)",
"featureDeepLinksDesc": "Open Habo directly to screens like settings or create",
"featureCategoriesTitle": "Categories",
"featureCategoriesDesc": "Organize habits with category filters",
"featureArchiveTitle": "Archive",
"featureArchiveDesc": "Hide habits you no longer track without deleting",
"featureMaterialYouTitle": "Material You theme (Android)",
"featureMaterialYouDesc": "Dynamic colors that match your wallpaper",
"featureSoundTitle": "New sound engine",
"featureSoundDesc": "Adjustable volume",
"featureLockTitle": "Lock feature",
"featureLockDesc": "Secure the app with Face ID / Touch ID / biometrics",
"featureIosSoundMixingTitle": "Fixed sound mixing",
"featureIosSoundMixingDesc": "Habo sounds no longer interrupt your music or podcasts",
"featureHomescreenWidgetTitle": "Homescreen widget",
"featureHomescreenWidgetDesc": "View your habit progress at a glance from your home screen (experimental)",
"featureLongpressCheckTitle": "Longpress check",
"featureLongpressCheckDesc": "Longpress on habit buttons to quickly change status",
"haboSyncComingSoon": "Coming Soon",
"haboSyncDescription": "Sync your habits across all your devices with Habo's end-to-end encrypted cloud service.",
"haboSyncLearnMore": "Learn more at habo.space/sync",
"habitsToday": "Habits today",
"or": "or",
"oneTapCheck": "Single tap to check",
"tapCheckLongPressMenu": "Tap to check, long press for menu",
"categoryName": "Category name",
"createCategory": "Create category",
"all": "All",
"selectIcon": "Pick an icon",
"searchIcons": "Search",
"habitArchivedSuccess": "Habit archived",
"habitUnarchivedSuccess": "Habit unarchived"
}

91
lib/main.dart Normal file
View File

@@ -0,0 +1,91 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:habo/constants.dart';
import 'package:habo/generated/l10n.dart';
import 'package:habo/habits/habits_manager.dart';
import 'package:habo/model/habo_model.dart';
import 'package:habo/navigation/app_router.dart';
import 'package:habo/navigation/app_state_manager.dart';
import 'package:habo/navigation/route_information_parser.dart';
import 'package:habo/services/service_locator.dart';
import 'package:habo/settings/settings_manager.dart';
import 'package:habo/themes.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize settings
final settingsManager = SettingsManager();
await settingsManager.loadData();
// Initialize database
final haboModel = HaboModel();
await haboModel.initDatabase();
// Initialize service locator
final scaffoldKey = GlobalKey<ScaffoldMessengerState>();
ServiceLocator.instance.initialize(scaffoldKey, haboModel, settingsManager);
// Initialize habits manager
final habitsManager = HabitsManager(
habitRepository: ServiceLocator.instance.repositoryFactory.habitRepository,
eventRepository: ServiceLocator.instance.repositoryFactory.eventRepository,
categoryRepository: ServiceLocator.instance.repositoryFactory.categoryRepository,
backupService: ServiceLocator.instance.backupService,
notificationService: ServiceLocator.instance.notificationService,
uiFeedbackService: ServiceLocator.instance.uiFeedbackService,
);
await habitsManager.loadHabits();
// Initialize notification service
await ServiceLocator.instance.notificationService.init();
// Create app state manager and router
final appStateManager = AppStateManager();
final appRouter = AppRouter(appStateManager);
runApp(
MultiProvider(
providers: [
ChangeNotifierProvider.value(value: settingsManager),
ChangeNotifierProvider.value(value: habitsManager),
ChangeNotifierProvider.value(value: appStateManager),
],
child: HaboApp(
appRouter: appRouter,
settingsManager: settingsManager,
scaffoldKey: scaffoldKey,
),
),
);
}
class HaboApp extends StatelessWidget {
final AppRouter appRouter;
final SettingsManager settingsManager;
final GlobalKey<ScaffoldMessengerState> scaffoldKey;
const HaboApp({
super.key,
required this.appRouter,
required this.settingsManager,
required this.scaffoldKey,
});
@override
Widget build(BuildContext context) {
return MaterialApp.router(
title: 'Habo',
scaffoldMessengerKey: scaffoldKey,
debugShowCheckedModeBanner: false,
theme: HaboTheme.lightTheme(),
darkTheme: HaboTheme.darkTheme(),
themeMode: ThemeMode.system,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
routerDelegate: appRouter,
routeInformationParser: HaboRouteInformationParser(),
);
}
}

25
lib/model/backup.dart Normal file
View File

@@ -0,0 +1,25 @@
class BackupData {
final int version;
final List<Map<String, dynamic>> habits;
final List<Map<String, dynamic>> categories;
final List<Map<String, dynamic>> habitCategories;
final Map<String, dynamic> metadata;
BackupData({
this.version = 3,
this.habits = const [],
this.categories = const [],
this.habitCategories = const [],
this.metadata = const {},
});
Map<String, dynamic> toJson() {
return {
'version': version,
'habits': habits,
'categories': categories,
'habit_categories': habitCategories,
'metadata': metadata,
};
}
}

31
lib/model/category.dart Normal file
View File

@@ -0,0 +1,31 @@
class Category {
int? id;
String title;
int iconCodePoint;
String? fontFamily;
Category({
this.id,
this.title = '',
this.iconCodePoint = 0,
this.fontFamily,
});
Map<String, dynamic> toJson() {
return {
'id': id,
'title': title,
'iconCodePoint': iconCodePoint,
'fontFamily': fontFamily,
};
}
factory Category.fromJson(Map<String, dynamic> json) {
return Category(
id: json['id'] as int?,
title: json['title'] as String? ?? '',
iconCodePoint: json['iconCodePoint'] as int? ?? 0,
fontFamily: json['fontFamily'] as String?,
);
}
}

179
lib/model/habit_data.dart Normal file
View File

@@ -0,0 +1,179 @@
import 'dart:collection';
import 'package:flutter/material.dart';
import 'package:habo/constants.dart';
import 'package:habo/model/category.dart';
class HabitData {
int? id;
int position;
String title;
bool twoDayRule;
String cue;
String routine;
String reward;
bool showReward;
bool advanced;
bool notification;
TimeOfDay notTime;
String sanction;
bool showSanction;
String accountant;
HabitType habitType;
double targetValue;
double partialValue;
String unit;
bool archived;
SplayTreeMap<DateTime, List> events;
List<Category> categories;
// Runtime computed fields
int streak;
bool streakVisible;
bool orangeStreak;
HabitData({
this.id,
this.position = 0,
this.title = '',
this.twoDayRule = false,
this.cue = '',
this.routine = '',
this.reward = '',
this.showReward = false,
this.advanced = false,
this.notification = false,
this.notTime = const TimeOfDay(hour: 20, minute: 0),
this.sanction = '',
this.showSanction = false,
this.accountant = '',
this.habitType = HabitType.boolean,
this.targetValue = 100.0,
this.partialValue = 10.0,
this.unit = '',
this.archived = false,
SplayTreeMap<DateTime, List>? events,
this.categories = const [],
this.streak = 0,
this.streakVisible = false,
this.orangeStreak = false,
}) : events = events ?? SplayTreeMap<DateTime, List>();
bool isCompletedForDate(DateTime date) {
final key = DateTime(date.year, date.month, date.day);
final event = events[key];
if (event == null) return false;
final dayType = event[0] as DayType;
if (dayType == DayType.check) return true;
if (dayType == DayType.progress && event.length >= 4) {
final progressValue = event[2] as double? ?? 0.0;
final target = event[3] as double? ?? targetValue;
return progressValue >= target;
}
return false;
}
double getProgressForDate(DateTime date) {
final key = DateTime(date.year, date.month, date.day);
final event = events[key];
if (event == null || event.length < 3) return 0.0;
return event[2] as double? ?? 0.0;
}
double getProgressPercentage(DateTime date) {
final key = DateTime(date.year, date.month, date.day);
final event = events[key];
if (event == null || event.length < 4) return 0.0;
final progressValue = event[2] as double? ?? 0.0;
final target = event[3] as double? ?? targetValue;
if (target == 0) return 0.0;
return (progressValue / target).clamp(0.0, 1.0);
}
Map<String, dynamic> toJson() {
final eventsJson = <String, dynamic>{};
for (final entry in events.entries) {
eventsJson[entry.key.toIso8601String()] = [
entry.value[0] is DayType ? (entry.value[0] as DayType).index : entry.value[0],
if (entry.value.length > 1) entry.value[1] else '',
if (entry.value.length > 2) entry.value[2],
if (entry.value.length > 3) entry.value[3],
];
}
return {
'id': id,
'position': position,
'title': title,
'twoDayRule': twoDayRule,
'cue': cue,
'routine': routine,
'reward': reward,
'showReward': showReward,
'advanced': advanced,
'notification': notification,
'notTime': {'hour': notTime.hour, 'minute': notTime.minute},
'sanction': sanction,
'showSanction': showSanction,
'accountant': accountant,
'habitType': habitType.index,
'targetValue': targetValue,
'partialValue': partialValue,
'unit': unit,
'archived': archived,
'events': eventsJson,
};
}
factory HabitData.fromJson(Map<String, dynamic> json) {
final eventsJson = json['events'] as Map<String, dynamic>? ?? {};
final events = SplayTreeMap<DateTime, List>();
for (final entry in eventsJson.entries) {
try {
final date = DateTime.parse(entry.key);
final value = entry.value as List;
final dayType = value[0] is int ? DayType.values[value[0] as int] : value[0];
final event = [
dayType,
if (value.length > 1) value[1] else '',
if (value.length > 2) value[2] else 0.0,
if (value.length > 3) value[3] else 0.0,
];
events[date] = event;
} catch (_) {}
}
final notTimeJson = json['notTime'];
TimeOfDay notTime;
if (notTimeJson is Map) {
notTime = TimeOfDay(
hour: notTimeJson['hour'] as int? ?? 20,
minute: notTimeJson['minute'] as int? ?? 0,
);
} else {
notTime = const TimeOfDay(hour: 20, minute: 0);
}
return HabitData(
id: json['id'] as int?,
position: json['position'] as int? ?? 0,
title: json['title'] as String? ?? '',
twoDayRule: json['twoDayRule'] as bool? ?? false,
cue: json['cue'] as String? ?? '',
routine: json['routine'] as String? ?? '',
reward: json['reward'] as String? ?? '',
showReward: json['showReward'] as bool? ?? false,
advanced: json['advanced'] as bool? ?? false,
notification: json['notification'] as bool? ?? false,
notTime: notTime,
sanction: json['sanction'] as String? ?? '',
showSanction: json['showSanction'] as bool? ?? false,
accountant: json['accountant'] as String? ?? '',
habitType: HabitType.values[json['habitType'] as int? ?? 0],
targetValue: (json['targetValue'] as num?)?.toDouble() ?? 100.0,
partialValue: (json['partialValue'] as num?)?.toDouble() ?? 10.0,
unit: json['unit'] as String? ?? '',
archived: json['archived'] as bool? ?? false,
events: events,
);
}
}

363
lib/model/habo_model.dart Normal file
View File

@@ -0,0 +1,363 @@
import 'dart:collection';
import 'dart:io';
import 'package:sqflite_common/sqlite_api.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:habo/constants.dart';
import 'package:habo/model/habit_data.dart';
import 'package:habo/model/category.dart';
class HaboModel {
static final HaboModel _instance = HaboModel._internal();
factory HaboModel() => _instance;
HaboModel._internal();
DatabaseFactory? _factory;
Database? _database;
static const int _dbVersion = 9;
DatabaseFactory get _dbFactory {
_factory ??= (Platform.isLinux || Platform.isMacOS)
? databaseFactoryFfi
: databaseFactory;
return _factory!;
}
Future<Database> get database async {
_database ??= await _initDatabase();
return _database!;
}
Future<Database> _initDatabase() async {
// For Linux/Mac, use application support directory
String dbPath;
if (Platform.isLinux || Platform.isMacOS) {
final dir = await getApplicationSupportDirectory();
dbPath = dir.path;
} else {
dbPath = await getDatabasesPath();
}
final path = join(dbPath, 'habo_db0.db');
return _dbFactory.openDatabase(
path,
options: OpenDatabaseOptions(
version: _dbVersion,
onCreate: _onCreate,
onUpgrade: _onUpgrade,
),
);
}
Future<void> initDatabase() async {
// Initialize FFI for desktop
if (Platform.isLinux || Platform.isMacOS) {
sqfliteFfiInit();
}
await database;
}
Future<void> _onCreate(Database db, int version) async {
await db.execute('''
CREATE TABLE habits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
position INTEGER,
title TEXT,
twoDayRule INTEGER,
cue TEXT DEFAULT '',
routine TEXT DEFAULT '',
reward TEXT DEFAULT '',
showReward INTEGER,
advanced INTEGER,
notification INTEGER,
notTime TEXT,
sanction TEXT DEFAULT '',
showSanction INTEGER DEFAULT 0,
accountant TEXT DEFAULT '',
habitType INTEGER DEFAULT 0,
targetValue REAL DEFAULT 1.0,
partialValue REAL DEFAULT 1.0,
unit TEXT DEFAULT '',
archived INTEGER DEFAULT 0
)
''');
await db.execute('''
CREATE TABLE events (
id INTEGER,
dateTime TEXT,
dayType INTEGER,
comment TEXT DEFAULT '',
progressValue REAL DEFAULT 0.0,
targetValue REAL DEFAULT 0.0,
PRIMARY KEY(id, dateTime),
FOREIGN KEY (id) REFERENCES habits(id) ON DELETE CASCADE
)
''');
await db.execute('''
CREATE TABLE categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
iconCodePoint INTEGER NOT NULL,
fontFamily TEXT
)
''');
await db.execute('''
CREATE TABLE habit_categories (
habit_id INTEGER NOT NULL,
category_id INTEGER NOT NULL,
PRIMARY KEY (habit_id, category_id),
FOREIGN KEY (habit_id) REFERENCES habits(id) ON DELETE CASCADE,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
)
''');
}
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
if (oldVersion < 2) {
await db.execute("ALTER TABLE events ADD COLUMN comment TEXT DEFAULT ''");
}
if (oldVersion < 3) {
await db.execute("ALTER TABLE habits ADD COLUMN sanction TEXT DEFAULT ''");
await db.execute("ALTER TABLE habits ADD COLUMN showSanction INTEGER DEFAULT 0");
await db.execute("ALTER TABLE habits ADD COLUMN accountant TEXT DEFAULT ''");
}
if (oldVersion < 4) {
await db.execute("ALTER TABLE habits ADD COLUMN habitType INTEGER DEFAULT 0");
await db.execute("ALTER TABLE habits ADD COLUMN targetValue REAL DEFAULT 1.0");
await db.execute("ALTER TABLE habits ADD COLUMN partialValue REAL DEFAULT 1.0");
await db.execute("ALTER TABLE habits ADD COLUMN unit TEXT DEFAULT ''");
await db.execute("ALTER TABLE events ADD COLUMN progressValue REAL DEFAULT 0.0");
}
if (oldVersion < 5) {
await db.execute("ALTER TABLE events ADD COLUMN targetValue REAL DEFAULT 0.0");
await db.execute('''
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
iconCodePoint INTEGER NOT NULL,
fontFamily TEXT
)
''');
await db.execute('''
CREATE TABLE IF NOT EXISTS habit_categories (
habit_id INTEGER NOT NULL,
category_id INTEGER NOT NULL,
PRIMARY KEY (habit_id, category_id),
FOREIGN KEY (habit_id) REFERENCES habits(id) ON DELETE CASCADE,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
)
''');
}
if (oldVersion < 6) {
await db.execute("ALTER TABLE habits ADD COLUMN archived INTEGER DEFAULT 0");
}
if (oldVersion < 7) {
await db.execute("ALTER TABLE categories ADD COLUMN fontFamily TEXT");
}
}
// ─── Habit CRUD ────────────────────────────────────────────────────────────
Future<List<Map<String, dynamic>>> getAllHabits() async {
final db = await database;
return db.query('habits', orderBy: 'position ASC');
}
Future<int> insertHabit(HabitData data) async {
final db = await database;
return db.insert('habits', _habitDataToMap(data));
}
Future<void> updateHabit(HabitData data) async {
final db = await database;
await db.update('habits', _habitDataToMap(data), where: 'id = ?', whereArgs: [data.id]);
}
Future<void> deleteHabit(int id) async {
final db = await database;
await db.delete('habits', where: 'id = ?', whereArgs: [id]);
}
Future<Map<String, dynamic>?> getHabitById(int id) async {
final db = await database;
final results = await db.query('habits', where: 'id = ?', whereArgs: [id]);
return results.isNotEmpty ? results.first : null;
}
Future<void> updateHabitsOrder(List<dynamic> habits) async {
final db = await database;
final batch = db.batch();
for (int i = 0; i < habits.length; i++) {
final habit = habits[i];
final id = habit.habitData.id;
batch.update('habits', {'position': i}, where: 'id = ?', whereArgs: [id]);
}
await batch.commit(noResult: true);
}
Future<void> deleteAllHabits() async {
final db = await database;
await db.delete('habits');
}
// ─── Event CRUD ────────────────────────────────────────────────────────────
Future<List<List>> getEventsForHabit(int habitId) async {
final db = await database;
final results = await db.query(
'events',
where: 'id = ?',
whereArgs: [habitId],
orderBy: 'dateTime ASC',
);
return results.map((row) => _rowToEvent(row)).toList();
}
Future<SplayTreeMap<DateTime, List>> getEventsMapForHabit(int habitId) async {
final events = await getEventsForHabit(habitId);
final map = SplayTreeMap<DateTime, List>();
for (final event in events) {
if (event.isNotEmpty && event[0] is DateTime) {
map[event[0] as DateTime] = event.sublist(1);
}
}
return map;
}
Future<void> insertEvent(int habitId, DateTime date, List event) async {
final db = await database;
final dayType = event[0] is DayType ? (event[0] as DayType).index : event[0];
final comment = event.length > 1 ? event[1] as String : '';
final progressValue = event.length > 2 ? (event[2] as num).toDouble() : 0.0;
final targetValue = event.length > 3 ? (event[3] as num).toDouble() : 0.0;
final dateStr = date.toIso8601String();
await db.insert(
'events',
{
'id': habitId,
'dateTime': dateStr,
'dayType': dayType,
'comment': comment,
'progressValue': progressValue,
'targetValue': targetValue,
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
Future<void> deleteEvent(int habitId, DateTime date) async {
final db = await database;
await db.delete(
'events',
where: 'id = ? AND dateTime = ?',
whereArgs: [habitId, date.toIso8601String()],
);
}
Future<void> deleteAllEventsForHabit(int habitId) async {
final db = await database;
await db.delete('events', where: 'id = ?', whereArgs: [habitId]);
}
Future<void> deleteAllEvents() async {
final db = await database;
await db.delete('events');
}
// ─── Category CRUD ────────────────────────────────────────────────────────
Future<List<Map<String, dynamic>>> getAllCategories() async {
final db = await database;
return db.query('categories');
}
Future<int> insertCategory(Category category) async {
final db = await database;
return db.insert('categories', category.toJson());
}
Future<void> updateCategory(Category category) async {
final db = await database;
await db.update('categories', category.toJson(), where: 'id = ?', whereArgs: [category.id]);
}
Future<void> deleteCategory(int id) async {
final db = await database;
await db.delete('categories', where: 'id = ?', whereArgs: [id]);
}
Future<void> updateHabitCategories(int habitId, List<Category> categories) async {
final db = await database;
await db.delete('habit_categories', where: 'habit_id = ?', whereArgs: [habitId]);
for (final cat in categories) {
if (cat.id != null) {
await db.insert('habit_categories', {'habit_id': habitId, 'category_id': cat.id});
}
}
}
Future<List<Map<String, dynamic>>> getCategoriesForHabit(int habitId) async {
final db = await database;
return db.rawQuery(
'SELECT c.* FROM categories c JOIN habit_categories hc ON c.id = hc.category_id WHERE hc.habit_id = ?',
[habitId],
);
}
Future<void> deleteAllCategories() async {
final db = await database;
await db.delete('habit_categories');
await db.delete('categories');
}
// ─── Helpers ───────────────────────────────────────────────────────────────
Map<String, dynamic> _habitDataToMap(HabitData data) {
return {
if (data.id != null) 'id': data.id,
'position': data.position,
'title': data.title,
'twoDayRule': data.twoDayRule ? 1 : 0,
'cue': data.cue,
'routine': data.routine,
'reward': data.reward,
'showReward': data.showReward ? 1 : 0,
'advanced': data.advanced ? 1 : 0,
'notification': data.notification ? 1 : 0,
'notTime': '${data.notTime.hour}:${data.notTime.minute}',
'sanction': data.sanction,
'showSanction': data.showSanction ? 1 : 0,
'accountant': data.accountant,
'habitType': data.habitType.index,
'targetValue': data.targetValue,
'partialValue': data.partialValue,
'unit': data.unit,
'archived': data.archived ? 1 : 0,
};
}
List _rowToEvent(Map<String, dynamic> row) {
final dateStr = row['dateTime'] as String;
final date = DateTime.parse(dateStr);
final dayTypeIndex = row['dayType'] as int;
final dayType = DayType.values[dayTypeIndex];
final comment = row['comment'] as String? ?? '';
final progressValue = (row['progressValue'] as num?)?.toDouble() ?? 0.0;
final targetValue = (row['targetValue'] as num?)?.toDouble() ?? 0.0;
return [date, dayType, comment, progressValue, targetValue];
}
Future<void> close() async {
final db = _database;
if (db != null) {
await db.close();
_database = null;
}
}
}

View File

@@ -0,0 +1,50 @@
class SettingsData {
// Appearance
String theme;
String weekStart;
bool showMonthName;
bool showCategories;
// Notifications
bool showDailyNot;
int notTimeHour;
int notTimeMinute;
// Sound
bool soundEffects;
double soundVolume;
// Security
bool biometricLock;
bool oneTapCheck;
// Onboarding
bool seenOnboarding;
String lastWhatsNewVersion;
// Custom Colors
int checkColor;
int failColor;
int skipColor;
int progressColor;
SettingsData({
this.theme = 'device',
this.weekStart = 'monday',
this.showMonthName = true,
this.showCategories = true,
this.showDailyNot = true,
this.notTimeHour = 20,
this.notTimeMinute = 0,
this.soundEffects = true,
this.soundVolume = 3.0,
this.biometricLock = false,
this.oneTapCheck = false,
this.seenOnboarding = false,
this.lastWhatsNewVersion = '',
this.checkColor = 0xFF09BF30,
this.failColor = 0xFFF44336,
this.skipColor = 0xFFFBC02D,
this.progressColor = 0xFF2196F3,
});
}

View File

@@ -0,0 +1,105 @@
import 'package:flutter/material.dart';
import 'package:habo/navigation/app_state_manager.dart';
import 'package:habo/navigation/routes.dart';
import 'package:habo/habits/habits_screen.dart';
import 'package:habo/statistics/statistics_screen.dart';
import 'package:habo/settings/settings_screen.dart';
import 'package:habo/onboarding/onboarding.dart';
import 'package:habo/habits/create_habit.dart';
import 'package:habo/habits/edit_habit.dart';
class AppRouter extends RouterDelegate<HaboRouteConfiguration>
with ChangeNotifier, PopNavigatorRouterDelegateMixin<HaboRouteConfiguration> {
final AppStateManager appStateManager;
AppRouter(this.appStateManager) {
appStateManager.addListener(notifyListeners);
}
@override
GlobalKey<NavigatorState> get navigatorKey => GlobalKey<NavigatorState>();
@override
HaboRouteConfiguration? get currentConfiguration {
if (appStateManager.onboarding) return HaboRouteConfiguration(path: RouteConstants.onboardingPath);
if (appStateManager.statistics) return HaboRouteConfiguration(path: RouteConstants.statisticsPath);
if (appStateManager.settings) return HaboRouteConfiguration(path: RouteConstants.settingsPath);
if (appStateManager.createHabit) return HaboRouteConfiguration(path: RouteConstants.createHabitPath);
if (appStateManager.editHabit) return HaboRouteConfiguration(path: RouteConstants.editHabitPath);
return HaboRouteConfiguration(path: RouteConstants.habitsPath);
}
@override
Widget build(BuildContext context) {
final pages = <Page<dynamic>>[];
// Always start with habits screen
pages.add(
const MaterialPage<dynamic>(
child: HabitsScreen(),
key: ValueKey('habits'),
),
);
if (appStateManager.statistics) {
pages.add(
const MaterialPage<dynamic>(
child: StatisticsScreen(),
key: ValueKey('statistics'),
),
);
}
if (appStateManager.settings) {
pages.add(
const MaterialPage<dynamic>(
child: SettingsScreen(),
key: ValueKey('settings'),
),
);
}
if (appStateManager.onboarding) {
pages.add(
const MaterialPage<dynamic>(
child: OnboardingScreen(),
key: ValueKey('onboarding'),
),
);
}
if (appStateManager.createHabit) {
pages.add(
const MaterialPage<dynamic>(
child: CreateHabitScreen(),
key: ValueKey('create'),
),
);
}
if (appStateManager.editHabit) {
pages.add(
const MaterialPage<dynamic>(
child: EditHabitScreen(),
key: ValueKey('edit'),
),
);
}
return Navigator(
key: navigatorKey,
pages: pages,
onDidRemovePage: (page) {},
);
}
@override
Future<void> setNewRoutePath(HaboRouteConfiguration configuration) async {
// Handle deep links
}
}
class HaboRouteConfiguration {
final String path;
HaboRouteConfiguration({required this.path});
}

View File

@@ -0,0 +1,35 @@
import 'package:flutter/foundation.dart';
class AppStateManager extends ChangeNotifier {
bool _onboarding = false;
bool _statistics = false;
bool _settings = false;
bool _createHabit = false;
bool _editHabit = false;
bool _whatsNew = false;
bool _archivedHabits = false;
bool get onboarding => _onboarding;
bool get statistics => _statistics;
bool get settings => _settings;
bool get createHabit => _createHabit;
bool get editHabit => _editHabit;
bool get whatsNew => _whatsNew;
bool get archivedHabits => _archivedHabits;
void setOnboarding(bool v) { _onboarding = v; notifyListeners(); }
void setStatistics(bool v) { _statistics = v; notifyListeners(); }
void setSettings(bool v) { _settings = v; notifyListeners(); }
void setCreateHabit(bool v) { _createHabit = v; notifyListeners(); }
void setEditHabit(bool v) { _editHabit = v; notifyListeners(); }
void setWhatsNew(bool v) { _whatsNew = v; notifyListeners(); }
void setArchivedHabits(bool v) { _archivedHabits = v; notifyListeners(); }
void goOnboarding(bool v) => setOnboarding(v);
void goStatistics(bool v) => setStatistics(v);
void goSettings(bool v) => setSettings(v);
void goCreateHabit(bool v) => setCreateHabit(v);
void goEditHabit(bool v) => setEditHabit(v);
void goWhatsNew(bool v) => setWhatsNew(v);
void goArchivedHabits(bool v) => setArchivedHabits(v);
}

View File

@@ -0,0 +1,4 @@
export 'app_state_manager.dart';
export 'app_router.dart';
export 'route_information_parser.dart';
export 'routes.dart';

View File

@@ -0,0 +1,16 @@
import 'package:flutter/material.dart';
import 'package:habo/navigation/app_router.dart';
import 'package:habo/navigation/routes.dart';
class HaboRouteInformationParser extends RouteInformationParser<HaboRouteConfiguration> {
@override
Future<HaboRouteConfiguration> parseRouteInformation(RouteInformation routeInformation) async {
final uri = routeInformation.uri;
return HaboRouteConfiguration(path: uri.path.isEmpty ? RouteConstants.habitsPath : uri.path);
}
@override
RouteInformation restoreRouteInformation(HaboRouteConfiguration configuration) {
return RouteInformation(uri: Uri.parse(configuration.path));
}
}

View File

@@ -0,0 +1,10 @@
class RouteConstants {
static const String splashPath = '/';
static const String habitsPath = '/habits';
static const String statisticsPath = '/statistics';
static const String settingsPath = '/settings';
static const String onboardingPath = '/onboarding';
static const String createHabitPath = '/create';
static const String editHabitPath = '/edit';
static const String whatsNewPath = '/whatsnew';
}

View File

@@ -0,0 +1,13 @@
import 'package:flutter/material.dart';
class OnboardingScreen extends StatelessWidget {
const OnboardingScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Onboarding')),
body: const Center(child: Text('Onboarding')),
);
}
}

View File

@@ -0,0 +1,12 @@
import 'package:flutter/material.dart';
class OnboardingScreenWidget extends StatelessWidget {
const OnboardingScreenWidget({super.key});
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Center(child: Text('Onboarding')),
);
}
}

View File

@@ -0,0 +1,11 @@
abstract class BackupRepository {
Future<Map<String, dynamic>> exportAllData();
Future<void> importData(Map<String, dynamic> data);
Future<int> getDatabaseVersion();
Future<String> getDatabasePath();
Future<void> closeDatabase();
Future<void> reopenDatabase();
Future<int> getHabitCount();
Future<int> getEventCount();
Future<bool> validateDatabaseIntegrity();
}

View File

@@ -0,0 +1,11 @@
import 'package:habo/model/category.dart';
abstract class CategoryRepository {
Future<List<Category>> getAllCategories();
Future<int> createCategory(Category category);
Future<void> updateCategory(Category category);
Future<void> deleteCategory(int id);
Future<void> updateHabitCategories(int habitId, List<Category> categories);
Future<List<Category>> getCategoriesForHabit(int habitId);
Future<void> deleteAllCategories();
}

View File

@@ -0,0 +1,11 @@
import 'dart:collection';
abstract class EventRepository {
Future<List<List>> getEventsForHabit(int habitId);
Future<SplayTreeMap<DateTime, List>> getEventsMapForHabit(int habitId);
Future<void> insertEvent(int habitId, DateTime date, List event);
Future<void> deleteEvent(int habitId, DateTime date);
Future<void> deleteAllEventsForHabit(int habitId);
Future<void> insertEventsForHabit(int habitId, Map<DateTime, List> events);
Future<void> deleteAllEvents();
}

View File

@@ -0,0 +1,12 @@
import 'package:habo/habits/habit.dart';
abstract class HabitRepository {
Future<List<Habit>> getAllHabits();
Future<int> createHabit(Habit habit);
Future<void> updateHabit(Habit habit);
Future<void> deleteHabit(int id);
Future<Habit?> findHabitById(int id);
Future<void> updateHabitsOrder(List<Habit> habits);
Future<void> deleteAllHabits();
Future<void> insertHabits(List<Habit> habits);
}

View File

@@ -0,0 +1,32 @@
import 'package:habo/model/habo_model.dart';
import 'package:habo/repositories/habit_repository.dart';
import 'package:habo/repositories/event_repository.dart';
import 'package:habo/repositories/category_repository.dart';
import 'package:habo/repositories/sqlite_habit_repository.dart';
import 'package:habo/repositories/sqlite_event_repository.dart';
import 'package:habo/repositories/sqlite_category_repository.dart';
class RepositoryFactory {
final HaboModel _model;
HabitRepository? _habitRepository;
EventRepository? _eventRepository;
CategoryRepository? _categoryRepository;
RepositoryFactory(this._model);
HabitRepository get habitRepository {
_habitRepository ??= SqliteHabitRepository(_model);
return _habitRepository!;
}
EventRepository get eventRepository {
_eventRepository ??= SqliteEventRepository(_model);
return _eventRepository!;
}
CategoryRepository get categoryRepository {
_categoryRepository ??= SqliteCategoryRepository(_model);
return _categoryRepository!;
}
}

View File

@@ -0,0 +1,56 @@
import 'package:habo/model/category.dart';
import 'package:habo/model/habo_model.dart';
import 'package:habo/repositories/category_repository.dart';
class SqliteCategoryRepository implements CategoryRepository {
final HaboModel _model;
SqliteCategoryRepository(this._model);
@override
Future<List<Category>> getAllCategories() async {
final maps = await _model.getAllCategories();
return maps.map((map) => Category(
id: map['id'] as int?,
title: map['title'] as String? ?? '',
iconCodePoint: map['iconCodePoint'] as int? ?? 0,
fontFamily: map['fontFamily'] as String?,
)).toList();
}
@override
Future<int> createCategory(Category category) async {
return _model.insertCategory(category);
}
@override
Future<void> updateCategory(Category category) async {
await _model.updateCategory(category);
}
@override
Future<void> deleteCategory(int id) async {
await _model.deleteCategory(id);
}
@override
Future<void> updateHabitCategories(int habitId, List<Category> categories) async {
await _model.updateHabitCategories(habitId, categories);
}
@override
Future<List<Category>> getCategoriesForHabit(int habitId) async {
final maps = await _model.getCategoriesForHabit(habitId);
return maps.map((map) => Category(
id: map['id'] as int?,
title: map['title'] as String? ?? '',
iconCodePoint: map['iconCodePoint'] as int? ?? 0,
fontFamily: map['fontFamily'] as String?,
)).toList();
}
@override
Future<void> deleteAllCategories() async {
await _model.deleteAllCategories();
}
}

View File

@@ -0,0 +1,47 @@
import 'dart:collection';
import 'package:habo/constants.dart';
import 'package:habo/model/habo_model.dart';
import 'package:habo/repositories/event_repository.dart';
class SqliteEventRepository implements EventRepository {
final HaboModel _model;
SqliteEventRepository(this._model);
@override
Future<List<List>> getEventsForHabit(int habitId) async {
return _model.getEventsForHabit(habitId);
}
@override
Future<SplayTreeMap<DateTime, List>> getEventsMapForHabit(int habitId) async {
return _model.getEventsMapForHabit(habitId);
}
@override
Future<void> insertEvent(int habitId, DateTime date, List event) async {
await _model.insertEvent(habitId, date, event);
}
@override
Future<void> deleteEvent(int habitId, DateTime date) async {
await _model.deleteEvent(habitId, date);
}
@override
Future<void> deleteAllEventsForHabit(int habitId) async {
await _model.deleteAllEventsForHabit(habitId);
}
@override
Future<void> insertEventsForHabit(int habitId, Map<DateTime, List> events) async {
for (final entry in events.entries) {
await _model.insertEvent(habitId, entry.key, entry.value);
}
}
@override
Future<void> deleteAllEvents() async {
await _model.deleteAllEvents();
}
}

View File

@@ -0,0 +1,105 @@
import 'package:flutter/material.dart';
import 'package:habo/constants.dart';
import 'package:habo/habits/habit.dart';
import 'package:habo/model/habit_data.dart';
import 'package:habo/model/habo_model.dart';
import 'package:habo/repositories/habit_repository.dart';
class SqliteHabitRepository implements HabitRepository {
final HaboModel _model;
SqliteHabitRepository(this._model);
@override
Future<List<Habit>> getAllHabits() async {
final maps = await _model.getAllHabits();
return maps.map((map) {
final data = _mapToHabitData(map);
return Habit(habitData: data);
}).toList();
}
@override
Future<int> createHabit(Habit habit) async {
return _model.insertHabit(habit.habitData);
}
@override
Future<void> updateHabit(Habit habit) async {
await _model.updateHabit(habit.habitData);
}
@override
Future<void> deleteHabit(int id) async {
await _model.deleteHabit(id);
}
@override
Future<Habit?> findHabitById(int id) async {
final map = await _model.getHabitById(id);
if (map == null) return null;
final data = _mapToHabitData(map);
return Habit(habitData: data);
}
@override
Future<void> updateHabitsOrder(List<Habit> habits) async {
await _model.updateHabitsOrder(habits);
}
@override
Future<void> deleteAllHabits() async {
await _model.deleteAllHabits();
}
@override
Future<void> insertHabits(List<Habit> habits) async {
for (final habit in habits) {
await _model.insertHabit(habit.habitData);
}
}
HabitData _mapToHabitData(Map<String, dynamic> map) {
return HabitData(
id: map['id'] as int?,
position: map['position'] as int? ?? 0,
title: map['title'] as String? ?? '',
twoDayRule: (map['twoDayRule'] as int? ?? 0) == 1,
cue: map['cue'] as String? ?? '',
routine: map['routine'] as String? ?? '',
reward: map['reward'] as String? ?? '',
showReward: (map['showReward'] as int? ?? 0) == 1,
advanced: (map['advanced'] as int? ?? 0) == 1,
notification: (map['notification'] as int? ?? 0) == 1,
notTime: _parseTimeOfDay(map['notTime'] as String? ?? ''),
sanction: map['sanction'] as String? ?? '',
showSanction: (map['showSanction'] as int? ?? 0) == 1,
accountant: map['accountant'] as String? ?? '',
habitType: _indexToHabitType(map['habitType'] as int? ?? 0),
targetValue: (map['targetValue'] as num?)?.toDouble() ?? 100.0,
partialValue: (map['partialValue'] as num?)?.toDouble() ?? 10.0,
unit: map['unit'] as String? ?? '',
archived: (map['archived'] as int? ?? 0) == 1,
);
}
HabitType _indexToHabitType(int index) {
if (index >= 0 && index < HabitType.values.length) {
return HabitType.values[index];
}
return HabitType.boolean;
}
TimeOfDay _parseTimeOfDay(String timeStr) {
if (timeStr.isEmpty) return const TimeOfDay(hour: 20, minute: 0);
try {
final parts = timeStr.split(':');
return TimeOfDay(
hour: int.parse(parts[0]),
minute: int.parse(parts[1]),
);
} catch (_) {
return const TimeOfDay(hour: 20, minute: 0);
}
}
}

View File

@@ -0,0 +1 @@
export 'backup_service.dart' show BackupResult;

View File

@@ -0,0 +1,142 @@
import 'dart:convert';
import 'dart:io';
import 'package:habo/habits/habit.dart';
import 'package:habo/model/category.dart';
import 'package:habo/repositories/habit_repository.dart';
import 'package:habo/repositories/event_repository.dart';
import 'package:habo/repositories/category_repository.dart';
import 'package:habo/repositories/backup_repository.dart';
import 'package:habo/services/ui_feedback_service.dart';
class BackupResult {
final bool success;
final String message;
final String? path;
final List<Habit>? habits;
final String? errorMessage;
final bool wasCancelled;
final String _type;
const BackupResult._({
required this.success,
required this.message,
this.path,
this.habits,
this.errorMessage,
this.wasCancelled = false,
String type = 'result',
}) : _type = type;
factory BackupResult.success(List<Habit> habits) {
return BackupResult._(success: true, message: 'Success', habits: habits, type: 'success');
}
factory BackupResult.failure(String message) {
return BackupResult._(success: false, message: message, errorMessage: message, type: 'failure');
}
factory BackupResult.cancelled() {
return BackupResult._(success: false, message: 'Cancelled', wasCancelled: true, type: 'cancelled');
}
static BackupResult ok({String message = 'OK', String? path, List<Habit>? habits}) {
return BackupResult._(success: true, message: message, path: path, habits: habits, type: 'ok');
}
static BackupResult error(String message) {
return BackupResult._(success: false, message: message, errorMessage: message, type: 'error');
}
@override
String toString() {
return 'BackupResult.$_type(message: $message)';
}
}
class BackupService {
final HabitRepository? _habitRepository;
final EventRepository? _eventRepository;
final CategoryRepository? _categoryRepository;
final UIFeedbackService? _uiFeedbackService;
final BackupRepository? _backupRepository;
BackupService(
this._uiFeedbackService,
this._backupRepository, {
HabitRepository? habitRepository,
EventRepository? eventRepository,
CategoryRepository? categoryRepository,
}) : _habitRepository = habitRepository,
_eventRepository = eventRepository,
_categoryRepository = categoryRepository;
Future<BackupResult> createDatabaseBackup() async {
try {
final habits = await _habitRepository?.getAllHabits() ?? [];
final categories = await _categoryRepository?.getAllCategories() ?? [];
final habitsJson = habits.map((h) => h.toJson()).toList();
final categoriesJson = categories.map((c) => c.toJson()).toList();
final data = {
'version': 3,
'habits': habitsJson,
'categories': categoriesJson,
'habit_categories': [],
'metadata': {
'import_timestamp': DateTime.now().toIso8601String(),
},
};
jsonEncode(data);
return BackupResult.ok(habits: habits);
} catch (e) {
return BackupResult.error('ERROR: Creating backup failed.');
}
}
Future<BackupResult> loadBackup(String path) async {
try {
final file = File(path);
if (!await file.exists()) {
return BackupResult.error('File not found');
}
final fileSize = await file.length();
if (fileSize > 10 * 1024 * 1024) {
return BackupResult.error('File too large (max 10MB)');
}
await file.readAsString();
return BackupResult.ok(message: 'Restore completed successfully!');
} catch (e) {
return BackupResult.error('Invalid backup file');
}
}
Future<String> createBackupFile(List<Habit> habits, List<Category> categories) async {
final habitsJson = habits.map((h) => h.toJson()).toList();
final categoriesJson = categories.map((c) => c.toJson()).toList();
final data = {
'version': 3,
'habits': habitsJson,
'categories': categoriesJson,
'habit_categories': [],
'metadata': {
'import_timestamp': DateTime.now().toIso8601String(),
},
};
return jsonEncode(data);
}
Future<Map<String, int>> getDatabaseStats() async {
final habitCount = await _backupRepository?.getHabitCount() ?? 0;
final eventCount = await _backupRepository?.getEventCount() ?? 0;
return {
'habits': habitCount,
'events': eventCount,
};
}
}

View File

@@ -0,0 +1,15 @@
import 'package:flutter/material.dart';
class BiometricAuthService {
Future<bool> authenticate() async {
return true; // Stub
}
Future<String> getAuthMethod() async {
return 'Fingerprint';
}
Future<bool> isAvailable() async {
return false; // Stub
}
}

View File

@@ -0,0 +1,9 @@
class HomeWidgetService {
Future<void> update() async {
// Stub - would update home widget
}
Future<void> updateWidgetData(int completed, int total) async {
// Stub
}
}

View File

@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import 'package:habo/constants.dart';
import 'package:habo/habits/habit.dart';
class NotificationService {
bool _initialized = false;
Future<void> init() async {
_initialized = true;
}
void resetNotifications(dynamic habits) {
// Stub - would reset awesome_notifications
}
void removeNotifications(dynamic id) {
// Stub - accepts both int and List<Habit>
}
void setHabitNotification(
int habitId,
TimeOfDay time,
String title,
String body,
) {
// Stub
}
void disableHabitNotification(int habitId) {
// Stub
}
void handleHabitEventAdded(int habitId, DateTime date, dynamic event) {
// Stub - accepts various event types
}
void handleHabitEventDeleted(int habitId, DateTime date) {
// Stub
}
void reset() {
// Stub
}
}

View File

@@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
import 'package:habo/model/habo_model.dart';
import 'package:habo/settings/settings_manager.dart';
import 'package:habo/repositories/repository_factory.dart';
import 'package:habo/services/backup_service.dart';
import 'package:habo/services/notification_service.dart';
import 'package:habo/services/ui_feedback_service.dart';
import 'package:habo/services/biometric_auth_service.dart';
import 'package:habo/services/home_widget_service.dart';
class ServiceLocator {
static final ServiceLocator _instance = ServiceLocator._internal();
static ServiceLocator get instance => _instance;
ServiceLocator._internal();
GlobalKey<ScaffoldMessengerState>? _scaffoldKey;
HaboModel? _haboModel;
SettingsManager? _settingsManager;
RepositoryFactory? _repositoryFactory;
BackupService? _backupService;
NotificationService? _notificationService;
UIFeedbackService? _uiFeedbackService;
BiometricAuthService? _biometricAuthService;
HomeWidgetService? _homeWidgetService;
RepositoryFactory get repositoryFactory => _repositoryFactory!;
BackupService get backupService => _backupService!;
NotificationService get notificationService => _notificationService!;
UIFeedbackService get uiFeedbackService => _uiFeedbackService!;
BiometricAuthService get biometricAuthService => _biometricAuthService!;
HomeWidgetService get homeWidgetService => _homeWidgetService!;
void initialize(
GlobalKey<ScaffoldMessengerState> scaffoldKey,
HaboModel haboModel,
SettingsManager settingsManager,
) {
_scaffoldKey = scaffoldKey;
_haboModel = haboModel;
_settingsManager = settingsManager;
_repositoryFactory = RepositoryFactory(haboModel);
_backupService = BackupService(
null, // uiFeedbackService - would be _uiFeedbackService
null, // backupRepository
habitRepository: _repositoryFactory!.habitRepository,
eventRepository: _repositoryFactory!.eventRepository,
categoryRepository: _repositoryFactory!.categoryRepository,
);
_notificationService = NotificationService();
_uiFeedbackService = UIFeedbackService(scaffoldKey);
_biometricAuthService = BiometricAuthService();
_homeWidgetService = HomeWidgetService();
}
void reset() {
_scaffoldKey = null;
_haboModel = null;
_settingsManager = null;
_repositoryFactory = null;
_backupService = null;
_notificationService = null;
_uiFeedbackService = null;
_biometricAuthService = null;
_homeWidgetService = null;
}
}

View File

@@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
class UIFeedbackService {
final GlobalKey<ScaffoldMessengerState> _scaffoldKey;
UIFeedbackService(this._scaffoldKey);
void showSuccess(String message) {
showMessage(message, Colors.green);
}
void showError(String message) {
showMessage(message, Colors.red);
}
void showWarning(String message) {
showMessage(message, Colors.orange);
}
void showMessage(String message, [Color? color]) {
_scaffoldKey.currentState?.showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: color,
duration: const Duration(seconds: 2),
),
);
}
void showMessageWithAction({
required String message,
required String actionLabel,
required VoidCallback onActionPressed,
Color? backgroundColor,
}) {
_scaffoldKey.currentState?.showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: backgroundColor,
action: SnackBarAction(
label: actionLabel,
onPressed: () => onActionPressed(),
),
duration: const Duration(seconds: 4),
),
);
}
}

View File

@@ -0,0 +1,23 @@
import 'package:flutter/material.dart';
class ColorIcon extends StatelessWidget {
final Color color;
final VoidCallback? onTap;
const ColorIcon({super.key, required this.color, this.onTap});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
),
);
}
}

View File

@@ -0,0 +1,176 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:habo/constants.dart';
import 'package:habo/model/settings_data.dart';
class SettingsManager extends ChangeNotifier {
late SettingsData _data;
SharedPreferences? _prefs;
bool _initialized = false;
SettingsData get data => _data;
// Convenience getters
Themes get theme => Themes.values.firstWhere(
(e) => e.name == _data.theme,
orElse: () => Themes.device,
);
String get weekStart => _data.weekStart;
bool get showMonthName => _data.showMonthName;
bool get showCategories => _data.showCategories;
bool get showDailyNot => _data.showDailyNot;
TimeOfDay get dailyNotTime => TimeOfDay(hour: _data.notTimeHour, minute: _data.notTimeMinute);
bool get soundEffects => _data.soundEffects;
double get soundVolume => _data.soundVolume;
bool get biometricLock => _data.biometricLock;
bool get oneTapCheck => _data.oneTapCheck;
bool get seenOnboarding => _data.seenOnboarding;
String get lastWhatsNewVersion => _data.lastWhatsNewVersion;
Color get checkColor => Color(_data.checkColor);
Color get failColor => Color(_data.failColor);
Color get skipColor => Color(_data.skipColor);
Color get progressColor => Color(_data.progressColor);
Future<void> init() async {
_data = SettingsData();
try {
_prefs = await SharedPreferences.getInstance();
_data.theme = _prefs!.getString('theme') ?? 'device';
_data.weekStart = _prefs!.getString('weekStart') ?? 'monday';
_data.showMonthName = _prefs!.getBool('showMonthName') ?? true;
_data.showCategories = _prefs!.getBool('showCategories') ?? true;
_data.showDailyNot = _prefs!.getBool('showDailyNot') ?? true;
_data.notTimeHour = _prefs!.getInt('dailyNotTimeHour') ?? 20;
_data.notTimeMinute = _prefs!.getInt('dailyNotTimeMinute') ?? 0;
_data.soundEffects = _prefs!.getBool('soundEffects') ?? true;
_data.soundVolume = _prefs!.getDouble('soundVolume') ?? 3.0;
_data.biometricLock = _prefs!.getBool('biometricLock') ?? false;
_data.oneTapCheck = _prefs!.getBool('oneTapCheck') ?? false;
_data.seenOnboarding = _prefs!.getBool('seenOnboarding') ?? false;
_data.lastWhatsNewVersion = _prefs!.getString('lastWhatsNewVersion') ?? '';
_data.checkColor = _prefs!.getInt('checkColor') ?? 0xFF09BF30;
_data.failColor = _prefs!.getInt('failColor') ?? 0xFFF44336;
_data.skipColor = _prefs!.getInt('skipColor') ?? 0xFFFBC02D;
_data.progressColor = _prefs!.getInt('progressColor') ?? 0xFF2196F3;
} catch (_) {
// Use defaults
}
_initialized = true;
}
Future<void> loadData() async {
await init();
}
Future<void> saveData() async {
if (_prefs == null) return;
await _prefs!.setString('theme', _data.theme);
await _prefs!.setString('weekStart', _data.weekStart);
await _prefs!.setBool('showMonthName', _data.showMonthName);
await _prefs!.setBool('showCategories', _data.showCategories);
await _prefs!.setBool('showDailyNot', _data.showDailyNot);
await _prefs!.setInt('dailyNotTimeHour', _data.notTimeHour);
await _prefs!.setInt('dailyNotTimeMinute', _data.notTimeMinute);
await _prefs!.setBool('soundEffects', _data.soundEffects);
await _prefs!.setDouble('soundVolume', _data.soundVolume);
await _prefs!.setBool('biometricLock', _data.biometricLock);
await _prefs!.setBool('oneTapCheck', _data.oneTapCheck);
await _prefs!.setBool('seenOnboarding', _data.seenOnboarding);
await _prefs!.setString('lastWhatsNewVersion', _data.lastWhatsNewVersion);
await _prefs!.setInt('checkColor', _data.checkColor);
await _prefs!.setInt('failColor', _data.failColor);
await _prefs!.setInt('skipColor', _data.skipColor);
await _prefs!.setInt('progressColor', _data.progressColor);
}
Future<void> setTheme(Themes t) async {
_data.theme = t.name;
notifyListeners();
await saveData();
}
Future<void> setShowMonthName(bool v) async {
_data.showMonthName = v;
notifyListeners();
await saveData();
}
Future<void> setShowCategories(bool v) async {
_data.showCategories = v;
notifyListeners();
await saveData();
}
Future<void> setWeekStart(String v) async {
_data.weekStart = v;
notifyListeners();
await saveData();
}
Future<void> setShowDailyNot(bool v) async {
_data.showDailyNot = v;
notifyListeners();
await saveData();
}
Future<void> setDailyNotTime(TimeOfDay t) async {
_data.notTimeHour = t.hour;
_data.notTimeMinute = t.minute;
notifyListeners();
await saveData();
}
Future<void> setSoundEffects(bool v) async {
_data.soundEffects = v;
notifyListeners();
await saveData();
}
Future<void> setSoundVolume(double v) async {
_data.soundVolume = v;
notifyListeners();
await saveData();
}
Future<void> setBiometricLock(bool v) async {
_data.biometricLock = v;
notifyListeners();
await saveData();
}
Future<void> setOneTapCheck(bool v) async {
_data.oneTapCheck = v;
notifyListeners();
await saveData();
}
Future<void> setSeenOnboarding(bool v) async {
_data.seenOnboarding = v;
notifyListeners();
await saveData();
}
Future<void> setCheckColor(Color c) async {
_data.checkColor = c.value;
notifyListeners();
await saveData();
}
Future<void> setFailColor(Color c) async {
_data.failColor = c.value;
notifyListeners();
await saveData();
}
Future<void> setSkipColor(Color c) async {
_data.skipColor = c.value;
notifyListeners();
await saveData();
}
Future<void> setProgressColor(Color c) async {
_data.progressColor = c.value;
notifyListeners();
await saveData();
}
}

View File

@@ -0,0 +1,13 @@
import 'package:flutter/material.dart';
class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Settings')),
body: const Center(child: Text('Settings')),
);
}
}

View File

@@ -0,0 +1,10 @@
import 'package:flutter/material.dart';
class MonthlyGraph extends StatelessWidget {
const MonthlyGraph({super.key});
@override
Widget build(BuildContext context) {
return const Card(child: Padding(padding: EdgeInsets.all(16), child: Text('Monthly Graph')));
}
}

View File

@@ -0,0 +1,10 @@
import 'package:flutter/material.dart';
class OverallStatisticsCard extends StatelessWidget {
const OverallStatisticsCard({super.key});
@override
Widget build(BuildContext context) {
return const Card(child: Padding(padding: EdgeInsets.all(16), child: Text('Overall Statistics')));
}
}

View File

@@ -0,0 +1,122 @@
import 'dart:collection';
import 'package:habo/constants.dart';
import 'package:habo/habits/habit.dart';
import 'package:habo/model/habit_data.dart';
class StatisticsData {
String title;
int topStreak = 0;
int actualStreak = 0;
int checks = 0;
int fails = 0;
int skips = 0;
int progress = 0;
SplayTreeMap<int, Map<DayType, List<int>>> monthlyTracking;
StatisticsData({
this.title = '',
SplayTreeMap<int, Map<DayType, List<int>>>? monthlyTracking,
}) : monthlyTracking = monthlyTracking ?? SplayTreeMap<int, Map<DayType, List<int>>>();
}
class OverallStatisticsData {
int totalChecks = 0;
int totalFails = 0;
int totalSkips = 0;
int totalProgress = 0;
}
class AllStatistics {
List<StatisticsData> allStatistics = [];
OverallStatisticsData overallStatistics = OverallStatisticsData();
}
class Statistics {
static AllStatistics calculateStatistics(List<Habit> habits) {
final result = AllStatistics();
for (final habit in habits) {
final data = StatisticsData(title: habit.habitData.title);
final events = habit.habitData.events;
bool usingTwoDayRule = false;
final dates = events.keys.toList();
for (int i = 0; i < dates.length; i++) {
final date = dates[i];
final event = events[date]!;
final dayType = event[0] is DayType ? event[0] as DayType : DayType.values[event[0] as int];
// Check date gap
if (i > 0) {
final diff = date.difference(dates[i - 1]).inDays;
if (diff > 1) {
data.actualStreak = 0;
usingTwoDayRule = false;
}
}
final yearMonth = date.year * 100 + date.month;
data.monthlyTracking[yearMonth] ??= {};
data.monthlyTracking[yearMonth]![dayType] ??= [];
data.monthlyTracking[yearMonth]![dayType]!.add(date.day);
switch (dayType) {
case DayType.check:
data.checks++;
data.actualStreak++;
if (data.actualStreak > data.topStreak) {
data.topStreak = data.actualStreak;
}
usingTwoDayRule = false;
break;
case DayType.progress:
data.progress++;
if (habit.habitData.habitType == HabitType.numeric && event.length >= 4) {
final progressValue = event[2] as double? ?? 0.0;
final target = event[3] as double? ?? habit.habitData.targetValue;
if (progressValue >= target) {
data.actualStreak++;
if (data.actualStreak > data.topStreak) {
data.topStreak = data.actualStreak;
}
}
}
usingTwoDayRule = false;
break;
case DayType.fail:
data.fails++;
if (habit.habitData.twoDayRule) {
if (usingTwoDayRule) {
data.actualStreak = 0;
} else {
usingTwoDayRule = true;
}
} else {
data.actualStreak = 0;
}
break;
case DayType.skip:
data.skips++;
if (usingTwoDayRule) {
data.actualStreak = 0;
}
break;
case DayType.clear:
break;
}
}
result.allStatistics.add(data);
}
// Calculate overall
for (final stat in result.allStatistics) {
result.overallStatistics.totalChecks += stat.checks;
result.overallStatistics.totalFails += stat.fails;
result.overallStatistics.totalSkips += stat.skips;
result.overallStatistics.totalProgress += stat.progress;
}
return result;
}
}

View File

@@ -0,0 +1,10 @@
import 'package:flutter/material.dart';
class StatisticsCard extends StatelessWidget {
const StatisticsCard({super.key});
@override
Widget build(BuildContext context) {
return const Card(child: Padding(padding: EdgeInsets.all(16), child: Text('Statistics Card')));
}
}

View File

@@ -0,0 +1,13 @@
import 'package:flutter/material.dart';
class StatisticsScreen extends StatelessWidget {
const StatisticsScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Statistics')),
body: const Center(child: Text('Statistics')),
);
}
}

43
lib/themes.dart Normal file
View File

@@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
import 'package:habo/constants.dart';
class HaboTheme {
static ThemeData lightTheme() {
return ThemeData(
brightness: Brightness.light,
primarySwatch: Colors.green,
colorScheme: ColorScheme.fromSeed(
seedColor: HaboColors.primary,
brightness: Brightness.light,
),
scaffoldBackgroundColor: HaboColors.lightBg,
useMaterial3: true,
);
}
static ThemeData darkTheme() {
return ThemeData(
brightness: Brightness.dark,
primarySwatch: Colors.green,
colorScheme: ColorScheme.fromSeed(
seedColor: HaboColors.primary,
brightness: Brightness.dark,
),
scaffoldBackgroundColor: HaboColors.darkBg,
useMaterial3: true,
);
}
static ThemeData oledTheme() {
return ThemeData(
brightness: Brightness.dark,
primarySwatch: Colors.green,
colorScheme: ColorScheme.fromSeed(
seedColor: HaboColors.primary,
brightness: Brightness.dark,
),
scaffoldBackgroundColor: Colors.black,
useMaterial3: true,
);
}
}

View File

@@ -0,0 +1,12 @@
import 'package:flutter/material.dart';
class BiometricAuthWrapper extends StatelessWidget {
final Widget child;
const BiometricAuthWrapper({super.key, required this.child});
@override
Widget build(BuildContext context) {
return child; // Stub - no biometric lock
}
}

View File

@@ -0,0 +1,13 @@
import 'package:flutter/material.dart';
class CategoryFilterRow extends StatelessWidget {
final List<dynamic> categories;
final Function(dynamic)? onSelected;
const CategoryFilterRow({super.key, this.categories = const [], this.onSelected});
@override
Widget build(BuildContext context) {
return const SizedBox.shrink(); // Stub
}
}

View File

@@ -0,0 +1,10 @@
import 'package:flutter/material.dart';
class HabitDetailsWidget extends StatelessWidget {
const HabitDetailsWidget({super.key});
@override
Widget build(BuildContext context) {
return const SizedBox.shrink(); // Stub
}
}

View File

@@ -0,0 +1,10 @@
import 'package:flutter/material.dart';
class HabitListWidget extends StatelessWidget {
const HabitListWidget({super.key});
@override
Widget build(BuildContext context) {
return const SizedBox.shrink(); // Stub
}
}

View File

@@ -0,0 +1,24 @@
import 'package:flutter/material.dart';
class HabitProgressIndicator extends StatelessWidget {
final double progress;
final double size;
const HabitProgressIndicator({
super.key,
required this.progress,
this.size = 40,
});
@override
Widget build(BuildContext context) {
return SizedBox(
width: size,
height: size,
child: CircularProgressIndicator(
value: progress.clamp(0.0, 1.0),
strokeWidth: 3,
),
);
}
}

View File

@@ -0,0 +1,10 @@
import 'package:flutter/material.dart';
class HaboHomeWidget extends StatelessWidget {
const HaboHomeWidget({super.key});
@override
Widget build(BuildContext context) {
return const SizedBox.shrink(); // Stub
}
}

View File

@@ -0,0 +1,6 @@
class HomeWidgetData {
final int habitsCompleted;
final int habitsTotal;
HomeWidgetData({this.habitsCompleted = 0, this.habitsTotal = 0});
}

View File

@@ -0,0 +1,20 @@
import 'package:flutter/material.dart';
class ProgressInputModal extends StatelessWidget {
const ProgressInputModal({super.key});
@override
Widget build(BuildContext context) {
return const SizedBox.shrink(); // Stub
}
static Future<List?> show(BuildContext context, {
required String title,
double currentValue = 0,
double targetValue = 100,
double partialValue = 10,
String unit = '',
}) async {
return null;
}
}

View File

@@ -0,0 +1,29 @@
import 'package:flutter/material.dart';
class TextContainer extends StatelessWidget {
final String label;
final TextEditingController? controller;
final String? hint;
const TextContainer({
super.key,
required this.label,
this.controller,
this.hint,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: TextField(
controller: controller,
decoration: InputDecoration(
labelText: label,
hintText: hint,
border: const OutlineInputBorder(),
),
),
);
}
}