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:
37
lib/constants.dart
Normal file
37
lib/constants.dart
Normal 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';
|
||||
}
|
||||
1460
lib/generated/app_localizations.dart
Normal file
1460
lib/generated/app_localizations.dart
Normal file
File diff suppressed because it is too large
Load Diff
753
lib/generated/app_localizations_en.dart
Normal file
753
lib/generated/app_localizations_en.dart
Normal 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
282
lib/generated/l10n.dart
Normal 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'),
|
||||
];
|
||||
}
|
||||
19
lib/habits/calendar_column.dart
Normal file
19
lib/habits/calendar_column.dart
Normal 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
|
||||
}
|
||||
}
|
||||
343
lib/habits/create_habit.dart
Normal file
343
lib/habits/create_habit.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
13
lib/habits/edit_habit.dart
Normal file
13
lib/habits/edit_habit.dart
Normal 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
53
lib/habits/habit.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
441
lib/habits/habits_manager.dart
Normal file
441
lib/habits/habits_manager.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
335
lib/habits/habits_screen.dart
Normal file
335
lib/habits/habits_screen.dart
Normal 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
19
lib/helpers.dart
Normal 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
264
lib/l10n/intl_en.arb
Normal 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
91
lib/main.dart
Normal 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
25
lib/model/backup.dart
Normal 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
31
lib/model/category.dart
Normal 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
179
lib/model/habit_data.dart
Normal 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
363
lib/model/habo_model.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
50
lib/model/settings_data.dart
Normal file
50
lib/model/settings_data.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
105
lib/navigation/app_router.dart
Normal file
105
lib/navigation/app_router.dart
Normal 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});
|
||||
}
|
||||
35
lib/navigation/app_state_manager.dart
Normal file
35
lib/navigation/app_state_manager.dart
Normal 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);
|
||||
}
|
||||
4
lib/navigation/navigation.dart
Normal file
4
lib/navigation/navigation.dart
Normal file
@@ -0,0 +1,4 @@
|
||||
export 'app_state_manager.dart';
|
||||
export 'app_router.dart';
|
||||
export 'route_information_parser.dart';
|
||||
export 'routes.dart';
|
||||
16
lib/navigation/route_information_parser.dart
Normal file
16
lib/navigation/route_information_parser.dart
Normal 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));
|
||||
}
|
||||
}
|
||||
10
lib/navigation/routes.dart
Normal file
10
lib/navigation/routes.dart
Normal 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';
|
||||
}
|
||||
13
lib/onboarding/onboarding.dart
Normal file
13
lib/onboarding/onboarding.dart
Normal 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')),
|
||||
);
|
||||
}
|
||||
}
|
||||
12
lib/onboarding/onboarding_screen.dart
Normal file
12
lib/onboarding/onboarding_screen.dart
Normal 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')),
|
||||
);
|
||||
}
|
||||
}
|
||||
11
lib/repositories/backup_repository.dart
Normal file
11
lib/repositories/backup_repository.dart
Normal 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();
|
||||
}
|
||||
11
lib/repositories/category_repository.dart
Normal file
11
lib/repositories/category_repository.dart
Normal 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();
|
||||
}
|
||||
11
lib/repositories/event_repository.dart
Normal file
11
lib/repositories/event_repository.dart
Normal 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();
|
||||
}
|
||||
12
lib/repositories/habit_repository.dart
Normal file
12
lib/repositories/habit_repository.dart
Normal 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);
|
||||
}
|
||||
32
lib/repositories/repository_factory.dart
Normal file
32
lib/repositories/repository_factory.dart
Normal 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!;
|
||||
}
|
||||
}
|
||||
56
lib/repositories/sqlite_category_repository.dart
Normal file
56
lib/repositories/sqlite_category_repository.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
47
lib/repositories/sqlite_event_repository.dart
Normal file
47
lib/repositories/sqlite_event_repository.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
105
lib/repositories/sqlite_habit_repository.dart
Normal file
105
lib/repositories/sqlite_habit_repository.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
1
lib/services/backup_result.dart
Normal file
1
lib/services/backup_result.dart
Normal file
@@ -0,0 +1 @@
|
||||
export 'backup_service.dart' show BackupResult;
|
||||
142
lib/services/backup_service.dart
Normal file
142
lib/services/backup_service.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
15
lib/services/biometric_auth_service.dart
Normal file
15
lib/services/biometric_auth_service.dart
Normal 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
|
||||
}
|
||||
}
|
||||
9
lib/services/home_widget_service.dart
Normal file
9
lib/services/home_widget_service.dart
Normal 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
|
||||
}
|
||||
}
|
||||
44
lib/services/notification_service.dart
Normal file
44
lib/services/notification_service.dart
Normal 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
|
||||
}
|
||||
}
|
||||
68
lib/services/service_locator.dart
Normal file
68
lib/services/service_locator.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
48
lib/services/ui_feedback_service.dart
Normal file
48
lib/services/ui_feedback_service.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
23
lib/settings/color_icon.dart
Normal file
23
lib/settings/color_icon.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
176
lib/settings/settings_manager.dart
Normal file
176
lib/settings/settings_manager.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
13
lib/settings/settings_screen.dart
Normal file
13
lib/settings/settings_screen.dart
Normal 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')),
|
||||
);
|
||||
}
|
||||
}
|
||||
10
lib/statistics/monthly_graph.dart
Normal file
10
lib/statistics/monthly_graph.dart
Normal 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')));
|
||||
}
|
||||
}
|
||||
10
lib/statistics/overall_statistics_card.dart
Normal file
10
lib/statistics/overall_statistics_card.dart
Normal 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')));
|
||||
}
|
||||
}
|
||||
122
lib/statistics/statistics.dart
Normal file
122
lib/statistics/statistics.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
10
lib/statistics/statistics_card.dart
Normal file
10
lib/statistics/statistics_card.dart
Normal 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')));
|
||||
}
|
||||
}
|
||||
13
lib/statistics/statistics_screen.dart
Normal file
13
lib/statistics/statistics_screen.dart
Normal 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
43
lib/themes.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
12
lib/widgets/biometric_auth_wrapper.dart
Normal file
12
lib/widgets/biometric_auth_wrapper.dart
Normal 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
|
||||
}
|
||||
}
|
||||
13
lib/widgets/category_filter_row.dart
Normal file
13
lib/widgets/category_filter_row.dart
Normal 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
|
||||
}
|
||||
}
|
||||
10
lib/widgets/habit_details_widget.dart
Normal file
10
lib/widgets/habit_details_widget.dart
Normal 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
|
||||
}
|
||||
}
|
||||
10
lib/widgets/habit_list_widget.dart
Normal file
10
lib/widgets/habit_list_widget.dart
Normal 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
|
||||
}
|
||||
}
|
||||
24
lib/widgets/habit_progress_indicator.dart
Normal file
24
lib/widgets/habit_progress_indicator.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
10
lib/widgets/habo_home_widget.dart
Normal file
10
lib/widgets/habo_home_widget.dart
Normal 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
|
||||
}
|
||||
}
|
||||
6
lib/widgets/home_widget_data.dart
Normal file
6
lib/widgets/home_widget_data.dart
Normal file
@@ -0,0 +1,6 @@
|
||||
class HomeWidgetData {
|
||||
final int habitsCompleted;
|
||||
final int habitsTotal;
|
||||
|
||||
HomeWidgetData({this.habitsCompleted = 0, this.habitsTotal = 0});
|
||||
}
|
||||
20
lib/widgets/progress_input_modal.dart
Normal file
20
lib/widgets/progress_input_modal.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
29
lib/widgets/text_container.dart
Normal file
29
lib/widgets/text_container.dart
Normal 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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user