diff --git a/app/README.md b/app/file_structure.md similarity index 96% rename from app/README.md rename to app/file_structure.md index ffd1b7c..896838f 100644 --- a/app/README.md +++ b/app/file_structure.md @@ -11,6 +11,7 @@ lib/ │ ├── cabinet_model.dart │ ├── extensions.dart │ ├── intake_model.dart +│ ├── medicine_category.dart │ └── profile_model.dart ├── pages/ │ ├── about_page.dart diff --git a/app/lib/db/README_DB.md b/app/lib/db/README_DB.md index eaabe50..6fcb588 100644 --- a/app/lib/db/README_DB.md +++ b/app/lib/db/README_DB.md @@ -19,11 +19,13 @@ ____________________________________________ cabinet structure: id --> integer AUTOINCREMENTED name --> text not null - dosage --> int not null + dosage --> text not null time --> time not null currstock --> int not null initstock --> int not null priority --> int not null + category --> text not null (default 'tablet') + unit --> text not null (default 'pills') ____________________________________________ create new medicine: createmed(cabinet 'model') @@ -44,4 +46,4 @@ create new log: createlog(intake 'model') read log: - readintakelog) \ No newline at end of file + readintakelog() \ No newline at end of file diff --git a/app/lib/db/cabinet_db.dart b/app/lib/db/cabinet_db.dart index ed14f70..26c2c3f 100644 --- a/app/lib/db/cabinet_db.dart +++ b/app/lib/db/cabinet_db.dart @@ -18,7 +18,12 @@ class DatabaseHelper { final dbPath = await getDatabasesPath(); final path = join(dbPath, filePath); - return await openDatabase(path, version: 1, onCreate: _createDB); + return await openDatabase( + path, + version: 2, + onCreate: _createDB, + onUpgrade: _upgradeDB, + ); } Future _createDB(Database db, int version) async { @@ -34,40 +39,47 @@ CREATE TABLE cabinet ( time $textType, currstock $integerType, initstock $integerType, - priority $integerType + priority $integerType, + category $textType DEFAULT 'tablet', + unit $textType DEFAULT 'pills' ) '''); } + Future _upgradeDB(Database db, int oldVersion, int newVersion) async { + if (oldVersion < 2) { + await db.execute( + "ALTER TABLE cabinet ADD COLUMN category TEXT NOT NULL DEFAULT 'tablet'", + ); + await db.execute( + "ALTER TABLE cabinet ADD COLUMN unit TEXT NOT NULL DEFAULT 'pills'", + ); + } + } + + static const String _ensureTable = ''' + CREATE TABLE IF NOT EXISTS cabinet ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + dosage TEXT NOT NULL, + time TEXT NOT NULL, + currstock INTEGER NOT NULL, + initstock INTEGER NOT NULL, + priority INTEGER NOT NULL, + category TEXT NOT NULL DEFAULT 'tablet', + unit TEXT NOT NULL DEFAULT 'pills' + ) + '''; + Future createMedicine(Cabinet cabinet) async { final db = await instance.database; - await db.execute(''' - CREATE TABLE IF NOT EXISTS cabinet ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - dosage TEXT NOT NULL, - time TEXT NOT NULL, - currstock INTEGER NOT NULL, - initstock INTEGER NOT NULL, - priority INTEGER NOT NULL - ) - '''); + await db.execute(_ensureTable); return await db.insert('cabinet', cabinet.toMap()); } Future readMedicine(int id) async { final db = await instance.database; - await db.execute(''' - CREATE TABLE IF NOT EXISTS cabinet ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - dosage TEXT NOT NULL, - time TEXT NOT NULL, - currstock INTEGER NOT NULL, - initstock INTEGER NOT NULL, - priority INTEGER NOT NULL - ) - '''); + await db.execute(_ensureTable); final maps = await db.query('cabinet', where: 'id = ?', whereArgs: [id]); if (maps.isNotEmpty) { @@ -78,17 +90,7 @@ CREATE TABLE cabinet ( Future> readAllMedicines() async { final db = await instance.database; - await db.execute(''' - CREATE TABLE IF NOT EXISTS cabinet ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - dosage TEXT NOT NULL, - time TEXT NOT NULL, - currstock INTEGER NOT NULL, - initstock INTEGER NOT NULL, - priority INTEGER NOT NULL - ) - '''); + await db.execute(_ensureTable); const orderBy = 'time ASC'; final result = await db.query('cabinet', orderBy: orderBy); return result.map((json) => Cabinet.fromMap(json)).toList(); @@ -96,17 +98,7 @@ CREATE TABLE cabinet ( Future updateMedicine(Cabinet medicine) async { final db = await instance.database; - await db.execute(''' - CREATE TABLE IF NOT EXISTS cabinet ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - dosage TEXT NOT NULL, - time TEXT NOT NULL, - currstock INTEGER NOT NULL, - initstock INTEGER NOT NULL, - priority INTEGER NOT NULL - ) - '''); + await db.execute(_ensureTable); return await db.update( 'cabinet', medicine.toMap(), @@ -117,17 +109,7 @@ CREATE TABLE cabinet ( Future deleteMedicine(int id) async { final db = await instance.database; - await db.execute(''' - CREATE TABLE IF NOT EXISTS cabinet ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - dosage TEXT NOT NULL, - time TEXT NOT NULL, - currstock INTEGER NOT NULL, - initstock INTEGER NOT NULL, - priority INTEGER NOT NULL - ) - '''); + await db.execute(_ensureTable); return await db.delete('cabinet', where: 'id = ?', whereArgs: [id]); } } diff --git a/app/lib/dose.dart b/app/lib/dose.dart index d2b0ce1..a2d4bbf 100644 --- a/app/lib/dose.dart +++ b/app/lib/dose.dart @@ -3,6 +3,7 @@ import 'package:dose/pages/home_page.dart'; import 'package:dose/pages/analytics_page.dart'; import 'package:dose/pages/profile_page.dart'; import 'package:dose/pages/add_menu_page.dart'; +import 'package:dose/models/medicine_category.dart'; class Dose extends StatefulWidget { const Dose({super.key}); @@ -16,6 +17,7 @@ class _DoseState extends State { final GlobalKey _scaffoldKey = GlobalKey(); Key _homeKey = UniqueKey(); + MedicineCategory? _selectedCategoryForNewMed; List _pages() => [ HomePage(key: _homeKey), @@ -53,6 +55,86 @@ class _DoseState extends State { super.dispose(); } + void _showCategoryBottomSheet(BuildContext context) { + showModalBottomSheet( + context: context, + backgroundColor: Theme.of(context).colorScheme.surface, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (context) { + final cs = Theme.of(context).colorScheme; + return Padding( + padding: EdgeInsets.only( + left: 24.0, + right: 24.0, + top: 24.0, + bottom: MediaQuery.of(context).padding.bottom + 24.0, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Add Medicine', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + color: cs.onSurface, + ), + ), + const SizedBox(height: 20), + Flexible( + child: ListView.separated( + shrinkWrap: true, + physics: const BouncingScrollPhysics(), + itemCount: MedicineCategory.values.length, + separatorBuilder: (context, index) => const SizedBox(height: 12), + itemBuilder: (context, index) { + final cat = MedicineCategory.values[index]; + return InkWell( + onTap: () { + Navigator.pop(context); + setState(() { + _selectedCategoryForNewMed = cat; + }); + _scaffoldKey.currentState?.openEndDrawer(); + }, + borderRadius: BorderRadius.circular(16), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + decoration: BoxDecoration( + border: Border.all( + color: cs.outlineVariant.withValues(alpha: 0.5), + width: 1.5), + borderRadius: BorderRadius.circular(16), + color: cs.surfaceContainerLowest, + ), + child: Row( + children: [ + Icon(cat.icon, size: 28, color: cs.onSurfaceVariant), + const SizedBox(width: 16), + Text( + cat.label, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: cs.onSurface, + ), + ), + ], + ), + ), + ); + }, + ), + ), + ], + ), + ); + }, + ); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -78,6 +160,7 @@ class _DoseState extends State { endDrawer: Drawer( width: MediaQuery.of(context).size.width, child: AddMedicineMenu( + initialCategory: _selectedCategoryForNewMed, onSave: () { setState(() { _homeKey = UniqueKey(); @@ -87,9 +170,7 @@ class _DoseState extends State { ), floatingActionButton: _selectedIndex == 0 ? FloatingActionButton( - onPressed: () { - _scaffoldKey.currentState?.openEndDrawer(); - }, + onPressed: () => _showCategoryBottomSheet(context), child: const Icon(Icons.add), ) : null, diff --git a/app/lib/models/cabinet_model.dart b/app/lib/models/cabinet_model.dart index 4df75cb..5026859 100644 --- a/app/lib/models/cabinet_model.dart +++ b/app/lib/models/cabinet_model.dart @@ -6,6 +6,8 @@ class Cabinet { final int initstock; final int currstock; final int priority; + final String category; + final String unit; Cabinet({ this.id, @@ -15,6 +17,8 @@ class Cabinet { required this.initstock, required this.currstock, required this.priority, + this.category = 'tablet', + this.unit = 'pills', }); Map toMap() { @@ -26,6 +30,8 @@ class Cabinet { 'initstock': initstock, 'currstock': currstock, 'priority': priority, + 'category': category, + 'unit': unit, }; } @@ -38,6 +44,8 @@ class Cabinet { initstock: map['initstock'], currstock: map['currstock'], priority: map['priority'], + category: map['category'] ?? 'tablet', + unit: map['unit'] ?? 'pills', ); } } diff --git a/app/lib/models/extensions.dart b/app/lib/models/extensions.dart index 116fb73..ecad45f 100644 --- a/app/lib/models/extensions.dart +++ b/app/lib/models/extensions.dart @@ -1,5 +1,8 @@ extension DoseString on String { + /// Formats a raw dosage value with its unit for display. + /// Example: "2" with unit "pills" → displayed via "$dosage $unit" at call site. + /// This getter strips any legacy 'pills/spoons' suffix left from old data. String get formattedDosage { - return replaceAll('mg', 'pills/spoons'); + return replaceAll(' pills/spoons', '').trim(); } } diff --git a/app/lib/models/medicine_category.dart b/app/lib/models/medicine_category.dart new file mode 100644 index 0000000..283890b --- /dev/null +++ b/app/lib/models/medicine_category.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; + +enum MedicineCategory { + tablet( + label: 'Tablet', + icon: Icons.medication, + units: ['pills', 'mg'], + ), + capsule( + label: 'Capsule', + icon: Icons.medication_outlined, + units: ['capsules', 'mg'], + ), + liquid( + label: 'Liquid', + icon: Icons.water_drop, + units: ['mL', 'spoons'], + ), + injection( + label: 'Injection', + icon: Icons.vaccines, + units: ['mL', 'units'], + ), + topical( + label: 'Topical', + icon: Icons.back_hand, + units: ['applications', 'mg'], + ), + inhaler( + label: 'Inhaler', + icon: Icons.air, + units: ['puffs', 'mg'], + ); + + final String label; + final IconData icon; + final List units; + + const MedicineCategory({ + required this.label, + required this.icon, + required this.units, + }); + + String get defaultUnit => units.first; + + /// Look up a category by its stored string name. + /// Returns [MedicineCategory.tablet] for unknown values. + static MedicineCategory fromString(String value) { + for (final cat in MedicineCategory.values) { + if (cat.name == value) return cat; + } + return MedicineCategory.tablet; + } +} diff --git a/app/lib/pages/add_menu_page.dart b/app/lib/pages/add_menu_page.dart index 382bfa5..1f62a81 100644 --- a/app/lib/pages/add_menu_page.dart +++ b/app/lib/pages/add_menu_page.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:dose/models/cabinet_model.dart'; +import 'package:dose/models/medicine_category.dart'; import 'package:dose/db/cabinet_db.dart'; import 'package:dose/services/notification_service.dart'; import 'package:dose/services/alarm_service.dart'; @@ -8,8 +9,14 @@ import 'package:dose/services/widget_service.dart'; class AddMedicineMenu extends StatefulWidget { final VoidCallback onSave; final Cabinet? medicineToEdit; + final MedicineCategory? initialCategory; - const AddMedicineMenu({super.key, required this.onSave, this.medicineToEdit}); + const AddMedicineMenu({ + super.key, + required this.onSave, + this.medicineToEdit, + this.initialCategory, + }); @override State createState() => _AddMedicineMenuState(); @@ -27,24 +34,27 @@ class _AddMedicineMenuState extends State { TimeOfDay _selectedTime = TimeOfDay.now(); String _cycle = '1/day'; int _priority = 1; - int _selectedType = 0; + late MedicineCategory _selectedCategory; + late String _selectedUnit; DateTime _selectedDate = DateTime.now(); - final List _medicineTypes = const [ - 'Tablet', - 'Capsule', - 'Liquid', - ]; - @override void initState() { super.initState(); + + _selectedCategory = widget.initialCategory ?? MedicineCategory.tablet; + _selectedUnit = _selectedCategory.defaultUnit; + if (widget.medicineToEdit != null) { final med = widget.medicineToEdit!; _nameController.text = med.name; _dosageController.text = med.dosage.replaceAll(' pills/spoons', ''); _stockController.text = med.currstock.toString(); _priority = med.priority; + _selectedCategory = MedicineCategory.fromString(med.category); + _selectedUnit = _selectedCategory.units.contains(med.unit) + ? med.unit + : _selectedCategory.defaultUnit; final parts = med.time.split(':'); if (parts.length == 2) { @@ -110,13 +120,15 @@ class _AddMedicineMenuState extends State { final medicine = Cabinet( id: widget.medicineToEdit?.id, name: _nameController.text, - dosage: "${_dosageController.text} pills/spoons", + dosage: _dosageController.text, time: timeString, currstock: int.tryParse(_stockController.text) ?? 0, initstock: widget.medicineToEdit != null ? widget.medicineToEdit!.initstock : (int.tryParse(_stockController.text) ?? 0), priority: _priority, + category: _selectedCategory.name, + unit: _selectedUnit, ); int savedId; @@ -172,6 +184,26 @@ class _AddMedicineMenuState extends State { ); } + InputDecorationTheme _dropdownDecorationTheme(ColorScheme cs) { + return InputDecorationTheme( + filled: true, + fillColor: cs.surfaceContainer, + contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(20), + borderSide: const BorderSide(width: 3.0), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(20), + borderSide: BorderSide(width: 3.0, color: cs.outlineVariant), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(20), + borderSide: BorderSide(width: 3.0, color: cs.primary), + ), + ); + } + @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; @@ -205,90 +237,6 @@ class _AddMedicineMenuState extends State { color: colorScheme.onSurface, ), ), - const SizedBox(height: 20), - - // Zone 2: Header Banner Card - Card( - margin: EdgeInsets.zero, - color: colorScheme.primaryContainer, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(28), - ), - elevation: 0, - child: Padding( - padding: const EdgeInsets.all(24.0), - child: Row( - children: [ - - Expanded( - child: ValueListenableBuilder( - valueListenable: _nameController, - builder: (context, value, child) { - final nameText = value.text.isEmpty ? 'Medicine Name' : value.text; - final alphaValue = value.text.isEmpty ? 0.5 : 1.0; - return Text( - nameText, - style: Theme.of(context) - .textTheme - .headlineMedium - ?.copyWith( - fontWeight: FontWeight.bold, - color: colorScheme.onPrimaryContainer - .withValues(alpha: alphaValue), - ), - ); - }, - ), - ), - ], - ), - ), - ), - const SizedBox(height: 24), - - // Medicine Type Chips - Row( - children: List.generate( - _medicineTypes.length, - (index) { - final isSelected = _selectedType == index; - return Expanded( - child: Padding( - padding: EdgeInsets.only( - right: index < _medicineTypes.length - 1 ? 12.0 : 0.0, - ), - child: ChoiceChip( - label: SizedBox( - width: double.infinity, - child: Center( - child: Text(_medicineTypes[index]), - ), - ), - selected: isSelected, - onSelected: (selected) { - if (selected) { - setState(() => _selectedType = index); - } - }, - selectedColor: colorScheme.secondaryContainer, - labelStyle: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: isSelected - ? colorScheme.onSecondaryContainer - : colorScheme.onSurfaceVariant, - ), - padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - showCheckmark: false, - ), - ), - ); - }, - ), - ), const SizedBox(height: 24), // Date/Time Row @@ -416,18 +364,70 @@ class _AddMedicineMenuState extends State { ), const SizedBox(height: 24), - // Zone 3: Form Fields + // Form Fields TextFormField( controller: _nameController, - decoration: _buildInputDecoration("Name"), + decoration: _buildInputDecoration("Medicine Name"), validator: (value) => value!.isEmpty ? 'Required' : null, ), const SizedBox(height: 16), + DropdownMenu( + initialSelection: _selectedCategory, + label: const Text("Category"), + expandedInsets: EdgeInsets.zero, + menuStyle: MenuStyle( + shape: WidgetStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + ), + inputDecorationTheme: _dropdownDecorationTheme(colorScheme), + dropdownMenuEntries: MedicineCategory.values + .map((cat) => DropdownMenuEntry( + value: cat, + label: cat.label, + leadingIcon: Icon(cat.icon), + )) + .toList(), + onSelected: (val) { + if (val != null) { + setState(() { + _selectedCategory = val; + _selectedUnit = val.defaultUnit; + }); + } + }, + ), + const SizedBox(height: 16), + + // Unit + Dosage Row Focus: Unit to the right of the box as suffix. TextFormField( controller: _dosageController, keyboardType: TextInputType.number, - decoration: _buildInputDecoration("Dosage"), + decoration: _buildInputDecoration("Dosage").copyWith( + suffixIcon: Padding( + padding: const EdgeInsets.only(right: 16.0), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: _selectedUnit, + icon: Icon(Icons.keyboard_arrow_down, color: colorScheme.primary), + style: TextStyle( + color: colorScheme.onSurface, + fontWeight: FontWeight.bold, + fontSize: 16, + ), + items: _selectedCategory.units + .map((u) => DropdownMenuItem(value: u, child: Text(u))) + .toList(), + onChanged: (val) { + if (val != null) setState(() => _selectedUnit = val); + }, + ), + ), + ), + ), validator: (value) => value!.isEmpty ? 'Required' : null, ), const SizedBox(height: 16), @@ -448,23 +448,7 @@ class _AddMedicineMenuState extends State { ), ), ), - inputDecorationTheme: InputDecorationTheme( - filled: true, - fillColor: colorScheme.surfaceContainer, - contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(20), - borderSide: const BorderSide(width: 3.0), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(20), - borderSide: BorderSide(width: 3.0, color: colorScheme.outlineVariant), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(20), - borderSide: BorderSide(width: 3.0, color: colorScheme.primary), - ), - ), + inputDecorationTheme: _dropdownDecorationTheme(colorScheme), dropdownMenuEntries: const [ DropdownMenuEntry(value: '6h', label: '6 hours'), DropdownMenuEntry(value: '12h', label: '12 hours'), @@ -493,28 +477,11 @@ class _AddMedicineMenuState extends State { ), ), ), - inputDecorationTheme: InputDecorationTheme( - filled: true, - fillColor: colorScheme.surfaceContainer, - contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(20), - borderSide: const BorderSide(width: 3.0), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(20), - borderSide: BorderSide(width: 3.0, color: colorScheme.outlineVariant), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(20), - borderSide: BorderSide(width: 3.0, color: colorScheme.primary), - ), - ), + inputDecorationTheme: _dropdownDecorationTheme(colorScheme), dropdownMenuEntries: const [ DropdownMenuEntry(value: 0, label: 'Low'), DropdownMenuEntry(value: 1, label: 'Medium'), DropdownMenuEntry(value: 2, label: 'High'), - // No leading icons needed since this is a simple priority, but could add them if requested. ], onSelected: (val) { if (val != null) setState(() => _priority = val); diff --git a/app/lib/pages/cabinet_page.dart b/app/lib/pages/cabinet_page.dart index 890f60b..23584c9 100644 --- a/app/lib/pages/cabinet_page.dart +++ b/app/lib/pages/cabinet_page.dart @@ -5,6 +5,7 @@ import 'package:dose/db/cabinet_db.dart'; import 'package:dose/pages/add_menu_page.dart'; import 'package:dose/services/widget_service.dart'; import 'package:dose/widgets/dose_card.dart'; +import 'package:dose/models/medicine_category.dart'; class CabinetPage extends StatefulWidget { const CabinetPage({super.key}); @@ -17,6 +18,7 @@ class _CabinetPageState extends State { late Future> _medicinesFuture; final GlobalKey _scaffoldKey = GlobalKey(); Cabinet? _editingMedicine; + MedicineCategory? _selectedCategoryForNewMed; @override void initState() { @@ -87,6 +89,87 @@ class _CabinetPageState extends State { _refreshMedicines(); } + void _showCategoryBottomSheet(BuildContext context) { + showModalBottomSheet( + context: context, + backgroundColor: Theme.of(context).colorScheme.surface, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (context) { + final cs = Theme.of(context).colorScheme; + return Padding( + padding: EdgeInsets.only( + left: 24.0, + right: 24.0, + top: 24.0, + bottom: MediaQuery.of(context).padding.bottom + 24.0, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Add Medicine', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + color: cs.onSurface, + ), + ), + const SizedBox(height: 20), + Flexible( + child: ListView.separated( + shrinkWrap: true, + physics: const BouncingScrollPhysics(), + itemCount: MedicineCategory.values.length, + separatorBuilder: (context, index) => const SizedBox(height: 12), + itemBuilder: (context, index) { + final cat = MedicineCategory.values[index]; + return InkWell( + onTap: () { + Navigator.pop(context); + setState(() { + _editingMedicine = null; + _selectedCategoryForNewMed = cat; + }); + _scaffoldKey.currentState?.openEndDrawer(); + }, + borderRadius: BorderRadius.circular(16), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + decoration: BoxDecoration( + border: Border.all( + color: cs.outlineVariant.withValues(alpha: 0.5), + width: 1.5), + borderRadius: BorderRadius.circular(16), + color: cs.surfaceContainerLowest, + ), + child: Row( + children: [ + Icon(cat.icon, size: 28, color: cs.onSurfaceVariant), + const SizedBox(width: 16), + Text( + cat.label, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: cs.onSurface, + ), + ), + ], + ), + ), + ); + }, + ), + ), + ], + ), + ); + }, + ); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -119,6 +202,7 @@ class _CabinetPageState extends State { width: MediaQuery.of(context).size.width, child: AddMedicineMenu( medicineToEdit: _editingMedicine, + initialCategory: _selectedCategoryForNewMed, onSave: () { setState(() { _editingMedicine = null; @@ -129,12 +213,7 @@ class _CabinetPageState extends State { ), ), floatingActionButton: FloatingActionButton( - onPressed: () { - setState(() { - _editingMedicine = null; - }); - _scaffoldKey.currentState?.openEndDrawer(); - }, + onPressed: () => _showCategoryBottomSheet(context), child: const Icon(Icons.add), ), body: FutureBuilder>( @@ -192,7 +271,7 @@ class _CabinetPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Dosage: ${medicine.dosage.formattedDosage}', + 'Dosage: ${medicine.dosage.formattedDosage} ${medicine.unit}', style: const TextStyle(fontSize: 14), ), const SizedBox(height: 4), diff --git a/app/lib/pages/home_page.dart b/app/lib/pages/home_page.dart index 27af727..f64ea17 100644 --- a/app/lib/pages/home_page.dart +++ b/app/lib/pages/home_page.dart @@ -1,6 +1,7 @@ import 'package:dose/models/cabinet_model.dart'; import 'package:dose/models/intake_model.dart' as log_model; import 'package:dose/models/extensions.dart'; +import 'package:dose/models/medicine_category.dart'; import 'package:flutter/material.dart'; import 'package:dose/db/cabinet_db.dart'; import 'package:dose/db/intake_log_db.dart' as log_db; @@ -73,9 +74,11 @@ class _HomePageState extends State { final lowStockMedicines = medicines .where((med) => med.currstock < 3) .toList(); - final Map uniqueMedicines = {}; + final Map uniqueMedicines = {}; for (final med in medicines) { - uniqueMedicines[med.name] = med.dosage; + if (!uniqueMedicines.containsKey(med.name)) { + uniqueMedicines[med.name] = med; + } } return ListView( @@ -196,7 +199,10 @@ class _HomePageState extends State { color: cs.tertiaryContainer, shape: BoxShape.circle, ), - child: Icon(Icons.medication, color: cs.onTertiaryContainer), + child: Icon( + MedicineCategory.fromString(med.category).icon, + color: cs.onTertiaryContainer, + ), ), const SizedBox(width: 16), Expanded( @@ -213,7 +219,7 @@ class _HomePageState extends State { ), const SizedBox(height: 4), Text( - med.dosage.formattedDosage, + '${med.dosage.formattedDosage} ${med.unit}', style: TextStyle(fontSize: 14, color: cs.onSurfaceVariant), ), ], @@ -247,7 +253,7 @@ class _HomePageState extends State { } Widget _buildCondensedList( - Map uniqueMedicines, + Map uniqueMedicines, ColorScheme cs, ) { return DoseCard( @@ -258,7 +264,7 @@ class _HomePageState extends State { itemCount: uniqueMedicines.length, itemBuilder: (context, index) { String name = uniqueMedicines.keys.elementAt(index); - String dosage = uniqueMedicines[name]!; + Cabinet med = uniqueMedicines[name]!; return Padding( padding: const EdgeInsets.symmetric( @@ -280,7 +286,7 @@ class _HomePageState extends State { ), ), Text( - dosage.formattedDosage, + '${med.dosage.formattedDosage} ${med.unit}', style: TextStyle(fontSize: 14, color: cs.onSurfaceVariant), ), ], diff --git a/app/lib/services/intake_service.dart b/app/lib/services/intake_service.dart index 7c7c33e..0715f74 100644 --- a/app/lib/services/intake_service.dart +++ b/app/lib/services/intake_service.dart @@ -43,6 +43,8 @@ class IntakeService { initstock: med.initstock, currstock: med.currstock - 1, priority: med.priority, + category: med.category, + unit: med.unit, ); await DatabaseHelper.instance.updateMedicine(updatedMed); diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 5aad8ff..a349772 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -1,5 +1,5 @@ name: dose -description: "Simple and modern medicine intake tracking app." +description: "Simple and modern medicine intake tracker." # The following line prevents the package from being accidentally published to # pub.dev using `flutter pub publish`. This is preferred for private packages. publish_to: 'none' # Remove this line if you wish to publish to pub.dev @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.9.0+1 +version: 0.9.1+2 environment: sdk: ^3.10.1 diff --git a/app/test/models/cabinet_model_test.dart b/app/test/models/cabinet_model_test.dart new file mode 100644 index 0000000..a5bfe6e --- /dev/null +++ b/app/test/models/cabinet_model_test.dart @@ -0,0 +1,106 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:dose/models/cabinet_model.dart'; + +void main() { + group('Cabinet.toMap', () { + test('includes category and unit fields', () { + final cab = Cabinet( + id: 1, + name: 'Aspirin', + dosage: '2', + time: '08:00', + initstock: 30, + currstock: 28, + priority: 1, + category: 'tablet', + unit: 'mg', + ); + final map = cab.toMap(); + expect(map['category'], 'tablet'); + expect(map['unit'], 'mg'); + expect(map['name'], 'Aspirin'); + expect(map['dosage'], '2'); + }); + }); + + group('Cabinet.fromMap', () { + test('parses all fields including category and unit', () { + final map = { + 'id': 5, + 'name': 'Ibuprofen', + 'dosage': '200', + 'time': '12:30', + 'initstock': 60, + 'currstock': 55, + 'priority': 2, + 'category': 'capsule', + 'unit': 'mg', + }; + final cab = Cabinet.fromMap(map); + expect(cab.id, 5); + expect(cab.name, 'Ibuprofen'); + expect(cab.dosage, '200'); + expect(cab.category, 'capsule'); + expect(cab.unit, 'mg'); + expect(cab.priority, 2); + }); + + test('defaults category to tablet and unit to pills when missing', () { + final map = { + 'id': 1, + 'name': 'OldMedicine', + 'dosage': '1', + 'time': '09:00', + 'initstock': 10, + 'currstock': 5, + 'priority': 0, + }; + final cab = Cabinet.fromMap(map); + expect(cab.category, 'tablet'); + expect(cab.unit, 'pills'); + }); + + test('defaults when category and unit are null', () { + final map = { + 'id': 2, + 'name': 'NullFields', + 'dosage': '3', + 'time': '14:00', + 'initstock': 20, + 'currstock': 18, + 'priority': 1, + 'category': null, + 'unit': null, + }; + final cab = Cabinet.fromMap(map); + expect(cab.category, 'tablet'); + expect(cab.unit, 'pills'); + }); + }); + + group('Cabinet round-trip', () { + test('toMap -> fromMap preserves all fields', () { + final original = Cabinet( + id: 10, + name: 'Cough Syrup', + dosage: '5', + time: '20:00', + initstock: 1, + currstock: 1, + priority: 0, + category: 'liquid', + unit: 'mL', + ); + final restored = Cabinet.fromMap(original.toMap()); + expect(restored.id, original.id); + expect(restored.name, original.name); + expect(restored.dosage, original.dosage); + expect(restored.time, original.time); + expect(restored.initstock, original.initstock); + expect(restored.currstock, original.currstock); + expect(restored.priority, original.priority); + expect(restored.category, original.category); + expect(restored.unit, original.unit); + }); + }); +} diff --git a/app/test/models/medicine_category_test.dart b/app/test/models/medicine_category_test.dart new file mode 100644 index 0000000..53b2db2 --- /dev/null +++ b/app/test/models/medicine_category_test.dart @@ -0,0 +1,43 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:dose/models/medicine_category.dart'; + +void main() { + group('MedicineCategory', () { + test('every category has at least one unit', () { + for (final cat in MedicineCategory.values) { + expect(cat.units, isNotEmpty, reason: '${cat.name} has no units'); + } + }); + + test('defaultUnit is the first entry in units', () { + for (final cat in MedicineCategory.values) { + expect( + cat.defaultUnit, + cat.units.first, + reason: '${cat.name} defaultUnit mismatch', + ); + } + }); + + test('fromString round-trips for all values', () { + for (final cat in MedicineCategory.values) { + expect(MedicineCategory.fromString(cat.name), cat); + } + }); + + test('fromString returns tablet for unknown value', () { + expect(MedicineCategory.fromString('unknown'), MedicineCategory.tablet); + expect(MedicineCategory.fromString(''), MedicineCategory.tablet); + }); + + test('all categories have distinct labels', () { + final labels = MedicineCategory.values.map((c) => c.label).toSet(); + expect(labels.length, MedicineCategory.values.length); + }); + + test('expected categories exist', () { + final names = MedicineCategory.values.map((c) => c.name).toSet(); + expect(names, containsAll(['tablet', 'capsule', 'liquid', 'injection', 'topical', 'inhaler'])); + }); + }); +}