diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 6320356..45ea60c 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -51,16 +51,16 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/speech_to_text/darwin" SPEC CHECKSUMS: - audioplayers_darwin: 877d9a4d06331c5c374595e46e16453ac7eafa40 + audioplayers_darwin: ccf9c770ee768abb07e26d90af093f7bab1c12ab Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 - flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069 - fluttertoast: e9a18c7be5413da53898f660530c56f35edfba9c - path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 - shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 - speech_to_text: 627d3fd2194770b51abb324ba45c2d39398f24a8 + flutter_keyboard_visibility: 4625131e43015dbbe759d9b20daaf77e0e3f6619 + fluttertoast: 76fea30fcf04176325f6864c87306927bd7d2038 + path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 + shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 + speech_to_text: 9dc43a5df3cbc2813f8c7cc9bd0fbf94268ed7ac Toast: 1f5ea13423a1e6674c4abdac5be53587ae481c4e Try: 5ef669ae832617b3cee58cb2c6f99fb767a4ff96 PODFILE CHECKSUM: 819463e6a0290f5a72f145ba7cde16e8b6ef0796 -COCOAPODS: 1.15.2 +COCOAPODS: 1.16.2 diff --git a/lib/enums/repeat_interval.dart b/lib/enums/repeat_interval.dart new file mode 100644 index 0000000..07431f0 --- /dev/null +++ b/lib/enums/repeat_interval.dart @@ -0,0 +1,14 @@ +enum RepeatInterval { + daily(1), + weekly(7), + monthly(30); + + const RepeatInterval(this.days); + final int days; +} + +extension ToString on RepeatInterval { + String getString() { + return "${name.toString()[0].toUpperCase()}${name.toString().substring(1)}"; + } +} diff --git a/lib/main.dart b/lib/main.dart index 4230dc5..0704e0a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,11 +1,13 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:taskly/providers/tags_provider.dart'; import 'package:taskly/providers/theme_provider.dart'; import 'package:taskly/screens/home_screen.dart'; import 'package:taskly/screens/intro_screen.dart'; import 'package:taskly/screens/splash_screen.dart'; import 'package:taskly/service/local_db_service.dart'; import 'package:taskly/service/speech_service.dart'; +import 'package:taskly/storage/tags_storage.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -28,20 +30,27 @@ class TasklyApp extends StatefulWidget { class _TasklyAppState extends State { late ThemeProvider themeProvider; - + TagsProvider tagsProvider = TagsProvider(); @override void initState() { super.initState(); - themeProvider = - ThemeProvider(); + themeProvider = ThemeProvider(); SpeechService.intialize(); + _loadTags(); + } + + void _loadTags() async { + tagsProvider.updateTags(await TagsStorage.loadTags()); } @override Widget build(BuildContext context) { - return ChangeNotifierProvider( - create: (context) => themeProvider, + return MultiProvider( + providers: [ + ChangeNotifierProvider(create: (_) => themeProvider), + ChangeNotifierProvider(create: (_) => tagsProvider), + ], child: Consumer( builder: (context, value, child) => MaterialApp( debugShowCheckedModeBanner: false, @@ -55,7 +64,8 @@ class _TasklyAppState extends State { // home: widget.isFirstTime ? const IntroScreen() : const HomeScreen(), initialRoute: '/', routes: { - '/': (context) => SplashScreen(), // Add Splash Screen as the initial route + '/': (context) => + SplashScreen(), // Add Splash Screen as the initial route '/main': (context) => widget.isFirstTime ? const IntroScreen() : const HomeScreen(), }, diff --git a/lib/models/tag.dart b/lib/models/tag.dart new file mode 100644 index 0000000..f04a817 --- /dev/null +++ b/lib/models/tag.dart @@ -0,0 +1,61 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; + +class Tag { + String id; + String title; + Color color; + + Tag({ + Color? color, + String? id, + required this.title, + }) : color = color ?? + Colors.primaries[Random().nextInt(Colors.primaries.length)], + id = id ?? DateTime.now().millisecondsSinceEpoch.toString(); + + Tag copyWith({ + String? id, + String? title, + Color? color, + }) { + return Tag( + id: id ?? this.id, + title: title ?? this.title, + color: color ?? this.color, + ); + } + + Map toMap() { + return { + 'id': id, + 'title': title, + 'color': color.value, + }; + } + + factory Tag.fromMap(Map map) { + return Tag( + id: map['id'] ?? '', + title: map['title'] ?? '', + color: Color(map['color']), + ); + } + + String toJson() => json.encode(toMap()); + + factory Tag.fromJson(String source) => Tag.fromMap(json.decode(source)); + + @override + String toString() => 'Tag(id: $id, title: $title, color: $color)'; + + @override + bool operator ==(Object other) { + return other is Tag && other.id == id; + } + + @override + int get hashCode => id.hashCode ^ title.hashCode ^ color.hashCode; +} diff --git a/lib/models/task.dart b/lib/models/task.dart index 1e8727a..d9a71a8 100644 --- a/lib/models/task.dart +++ b/lib/models/task.dart @@ -7,9 +7,22 @@ class Task { DateTime deadline; bool hasDeadline; Color color; + int? recurringDays; + List tags; // the tag ids - Task({required this.title, this.description = '', this.isCompleted = false, DateTime? deadline, this.hasDeadline = false,this.color = Colors.blue }) - : deadline = deadline ?? DateTime.now(); + bool get isRecurring => recurringDays != null; + + Task({ + required this.title, + this.description = '', + this.isCompleted = false, + DateTime? deadline, + this.hasDeadline = false, + this.color = Colors.blue, + this.recurringDays, + List? tags, + }) : deadline = deadline ?? DateTime.now(), + tags = tags ?? []; // Convert a Task object to JSON Map toJson() { @@ -19,7 +32,9 @@ class Task { 'isCompleted': isCompleted, 'deadline': deadline.toIso8601String(), 'hasDeadline': hasDeadline, + 'recurringDays': recurringDays, 'color': color.value, + 'tags': tags, }; } @@ -31,7 +46,21 @@ class Task { isCompleted: json['isCompleted'], deadline: DateTime.parse(json['deadline']), hasDeadline: json['hasDeadline'], + recurringDays: json['recurringDays'], color: Color(json['color']), + tags: List.from(json['tags']), ); } + + void toggleCompletion() { + if (!isRecurring) { + isCompleted = !isCompleted; + return; + } + + if (hasDeadline) { + deadline = deadline.add(Duration(days: recurringDays!)); + return; + } + } } diff --git a/lib/providers/tags_provider.dart b/lib/providers/tags_provider.dart new file mode 100644 index 0000000..f4fbfca --- /dev/null +++ b/lib/providers/tags_provider.dart @@ -0,0 +1,11 @@ +import 'package:flutter/material.dart'; +import 'package:taskly/models/tag.dart'; + +class TagsProvider extends ChangeNotifier { + List allTags = []; + + void updateTags(List tags) { + allTags = tags; + notifyListeners(); + } +} diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 5fdbd04..bbb3da0 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -71,7 +71,7 @@ class _HomeScreenState extends State { void _toggleTaskCompletion(int index, bool? value) async { setState(() { - tasks[index].isCompleted = value ?? false; + tasks[index].toggleCompletion(); if (tasks[index].isCompleted) { if (tasks[index].hasDeadline) { @@ -135,11 +135,9 @@ class _HomeScreenState extends State { } else if (option == TaskOption.launchMeditationScreen) { Navigator.push(context, MaterialPageRoute(builder: (context) => const MeditationScreen())); - } - else if (option == TaskOption.exportToCSV) { + } else if (option == TaskOption.exportToCSV) { exportToCSV(tasks); } - }); } @@ -157,39 +155,47 @@ class _HomeScreenState extends State { await TaskStorage.saveTasks(tasks); } } -void exportToCSV(List tasks) async { - // Prepare CSV data - List> rows = []; - // Add header - rows.add(["Title", "Description", "Is Completed","Has Deadline","Deadline"]); + void exportToCSV(List tasks) async { + // Prepare CSV data + List> rows = []; - // Add data rows - for (var task in tasks) { - rows.add([task.title, task.description, task.isCompleted,task.hasDeadline,'${task.deadline.day}/${task.deadline.month}/${task.deadline.year}']); - } + // Add header + rows.add( + ["Title", "Description", "Is Completed", "Has Deadline", "Deadline"]); - // Convert to CSV string - String csv = const ListToCsvConverter().convert(rows); + // Add data rows + for (var task in tasks) { + rows.add([ + task.title, + task.description, + task.isCompleted, + task.hasDeadline, + '${task.deadline.day}/${task.deadline.month}/${task.deadline.year}' + ]); + } - // Open directory picker - String? directory = await FilePicker.platform.getDirectoryPath(); + // Convert to CSV string + String csv = const ListToCsvConverter().convert(rows); - if (directory == null) { - // User canceled the picker - print("Export canceled."); - return; - } + // Open directory picker + String? directory = await FilePicker.platform.getDirectoryPath(); - // Create the file path - final path = "$directory/tasks.csv"; + if (directory == null) { + // User canceled the picker + print("Export canceled."); + return; + } - // Write the CSV file - final file = File(path); - await file.writeAsString(csv); + // Create the file path + final path = "$directory/tasks.csv"; - print("File saved at: $path"); -} + // Write the CSV file + final file = File(path); + await file.writeAsString(csv); + + print("File saved at: $path"); + } void _loadKudos() async { Kudos loadedKudos = await KudosStorage.loadKudos(); diff --git a/lib/screens/taskform_screen.dart b/lib/screens/taskform_screen.dart index faecf5a..d4b64af 100644 --- a/lib/screens/taskform_screen.dart +++ b/lib/screens/taskform_screen.dart @@ -1,9 +1,13 @@ import 'package:flutter/material.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:flutter_colorpicker/flutter_colorpicker.dart'; +import 'package:taskly/models/tag.dart'; import 'package:taskly/models/task.dart'; import 'package:taskly/service/speech_service.dart'; import 'package:taskly/utils/date_utils.dart'; +import 'package:taskly/widgets/repeat_select_card.dart'; +import 'package:taskly/widgets/spacing.dart'; +import 'package:taskly/widgets/tags_card.dart'; class TaskFormScreen extends StatefulWidget { final Task? task; @@ -21,6 +25,8 @@ class _TaskFormScreenState extends State { var hasDeadline = false; DateTime? deadline; Color selectedColor = Colors.blue; + int? repeatInterval; + late List tags; bool isTitleListening = false; @@ -33,6 +39,9 @@ class _TaskFormScreenState extends State { hasDeadline = widget.task?.hasDeadline ?? false; deadline = widget.task?.deadline; selectedColor = widget.task?.color ?? Colors.blue; + repeatInterval = + widget.task?.recurringDays == 0 ? null : widget.task?.recurringDays; + tags = widget.task?.tags ?? []; } void _showColorPicker() { @@ -88,7 +97,206 @@ class _TaskFormScreenState extends State { } else { _startListening(controller); } + } + + List _buildTextfields() { + return [ + TextFormField( + controller: _titleController, + decoration: InputDecoration( + labelText: 'Task Title', + suffixIcon: IconButton( + onPressed: () { + isTitleListening = true; + _toggleMic(_titleController); + }, + icon: Icon(SpeechService.isListening() & isTitleListening + ? Icons.circle_rounded + : Icons.mic_rounded), + ), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return "Title cannot be empty!"; + } + return null; + }, + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 15.0), + child: TextFormField( + controller: _descController, + decoration: InputDecoration( + labelText: 'Task Description', + hintText: 'Enter a detailed description...', + alignLabelWithHint: true, + suffixIcon: IconButton( + onPressed: () { + isTitleListening = false; + _toggleMic(_descController); + }, + icon: Icon(SpeechService.isListening() & !isTitleListening + ? Icons.circle_rounded + : Icons.mic_rounded), + ), + border: + const OutlineInputBorder(), // Adds a border for a defined look + contentPadding: const EdgeInsets.symmetric( + vertical: 15, + horizontal: 12), // Adds padding inside the text field + ), + maxLines: 6, // Provides a reasonable height for multi-line input + minLines: 4, // Ensures the field has a minimum height + validator: (value) { + if (value == null || value.isEmpty) { + return "Description cannot be empty!"; + } + return null; + }, + ), + ) + ]; + } + Widget _buildCard( + String title, String subtitle, Widget trailing, VoidCallback onTap) { + return Card( + margin: const EdgeInsets.all(0), + child: ListTile( + title: Text(title), + subtitle: Text(subtitle), + trailing: trailing, + onTap: onTap, + ), + ); + } + + List _buildColorDeadlineRepeat() { + Widget deadlineTrailing; + if (deadline != null) { + deadlineTrailing = Text(MyDateUtils.getFormattedDate(deadline!)); + } else { + deadlineTrailing = const Icon(Icons.date_range_rounded); + } + + Widget repeatTrailing; + if (repeatInterval != null) { + repeatTrailing = Text("$repeatInterval days"); + } else { + repeatTrailing = const Icon(Icons.repeat_rounded); + } + + Widget tagsTrailing; + if (tags.isEmpty) { + tagsTrailing = const Icon(Icons.label_outline_rounded); + } else { + tagsTrailing = Text( + tags.length.toString(), + style: Theme.of(context).textTheme.bodyLarge, + ); + } + + return [ + _buildCard( + "Colour", + "Select a colour for your task", + Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: selectedColor, + borderRadius: BorderRadius.circular(4), + ), + ), + _showColorPicker, + ), + const Spacing(), + _buildCard( + "Deadline", + "Select a deadline for your task", + deadlineTrailing, + () async { + final selectedDate = await showDatePicker( + context: context, + initialDate: DateTime.now(), + firstDate: DateTime.now(), + lastDate: DateTime(2025), + ); + if (selectedDate != null) { + setState(() { + hasDeadline = true; + deadline = selectedDate; + }); + } + }, + ), + const Spacing(), + _buildCard( + "Repeat", + "Select repeat interval for your task", + repeatTrailing, + () async { + int? days = await showDialog( + context: context, + builder: (context) { + return RepeatSelectCard(repeatInterval: repeatInterval); + }, + ); + if (days != null) { + if (!hasDeadline) { + hasDeadline = true; + deadline = DateTime.now(); + } + } + setState(() { + repeatInterval = days; + }); + }, + ), + const Spacing(), + _buildCard( + "Tags", + "Select tags for your task", + tagsTrailing, + () async { + List? allTags = await showModalBottomSheet( + context: context, + useRootNavigator: true, + builder: (context) => TagsCard(tags: tags), + ); + + if (allTags != null) { + setState(() { + tags = allTags.map((e) => e.id).toList(); + }); + } + }, + ), + ]; + } + + Widget _buildSaveButton() { + return ElevatedButton( + onPressed: () { + if (_key.currentState!.validate()) { + Task task = Task( + title: _titleController.text, + description: _descController.text, + hasDeadline: hasDeadline, + deadline: hasDeadline ? deadline : null, + recurringDays: repeatInterval, + color: selectedColor, + tags: tags, + ); + Fluttertoast.showToast( + msg: editing + ? "Task Successfully Edited!" + : "Task Successfully Created!"); + Navigator.pop(context, task); + } + }, + child: const Text('Save Task'), + ); } @override @@ -97,6 +305,12 @@ class _TaskFormScreenState extends State { appBar: AppBar( title: editing ? const Text("Edit Task") : const Text('Add Task'), ), + persistentFooterButtons: [ + SizedBox( + width: double.infinity, + child: _buildSaveButton(), + ), + ], body: SingleChildScrollView( child: Padding( padding: const EdgeInsets.all(16.0), @@ -105,125 +319,9 @@ class _TaskFormScreenState extends State { autovalidateMode: AutovalidateMode.onUserInteraction, child: Column( children: [ - TextFormField( - controller: _titleController, - decoration: InputDecoration( - labelText: 'Task Title', - suffixIcon: IconButton( - onPressed: () { - isTitleListening = true; - _toggleMic(_titleController); - }, - icon: Icon(SpeechService.isListening() & isTitleListening - ? Icons.circle_rounded - : Icons.mic_rounded), - ), - ), - validator: (value) { - if (value == null || value.isEmpty) { - return "Title cannot be empty!"; - } - return null; - }, - ), - Padding( - padding: const EdgeInsets.symmetric(vertical: 15.0), - child: TextFormField( - controller: _descController, - decoration: InputDecoration( - labelText: 'Task Description', - hintText: 'Enter a detailed description...', - alignLabelWithHint: true, - suffixIcon: IconButton( - onPressed: () { - isTitleListening = false; - _toggleMic(_descController); - }, - icon: Icon( - SpeechService.isListening() & !isTitleListening - ? Icons.circle_rounded - : Icons.mic_rounded), - ), - border: - const OutlineInputBorder(), // Adds a border for a defined look - contentPadding: const EdgeInsets.symmetric( - vertical: 15, - horizontal: 12), // Adds padding inside the text field - ), - maxLines: - 6, // Provides a reasonable height for multi-line input - minLines: 4, // Ensures the field has a minimum height - validator: (value) { - if (value == null || value.isEmpty) { - return "Description cannot be empty!"; - } - return null; - }, - ), - ), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - ElevatedButton( - onPressed: _showColorPicker, - child: const Text('Choose Task Color'), - ), - ], - ), - const SizedBox(height: 20), - // boolformfield for a bool value (hasDeadline) - // CheckboxListTile(value: hasDeadline, onChanged: (value)=>{ - // setState(() { - // hasDeadline = value!; - // }) - // }, title: const Text('Has Deadline')), - + ..._buildTextfields(), const SizedBox(height: 5), - // Date picker field for a DateTime value (deadline) - if (deadline != null) - Text( - "Selected Deadline - ${MyDateUtils.getFormattedDate(deadline!)}"), - const SizedBox( - height: 5, - ), - ElevatedButton( - onPressed: () async { - final selectedDate = await showDatePicker( - context: context, - initialDate: DateTime.now(), - firstDate: DateTime.now(), - lastDate: DateTime.now().add(const Duration(days: 365)), - ); - if (selectedDate != null) { - setState(() { - hasDeadline = true; - deadline = selectedDate; - }); - } - }, - child: const Text('Select Deadline'), - ), - - const SizedBox(height: 20), - ElevatedButton( - onPressed: () { - if (_key.currentState!.validate()) { - Task task = Task( - title: _titleController.text, - description: _descController.text, - hasDeadline: hasDeadline, - deadline: hasDeadline ? deadline : null, - color: selectedColor, - ); - Fluttertoast.showToast( - msg: editing - ? "Task Successfully Edited!" - : "Task Successfully Created!"); - Navigator.pop(context, task); - } - }, - child: const Text('Save Task'), - ), + ..._buildColorDeadlineRepeat(), ], ), ), diff --git a/lib/screens/tasklist_screen.dart b/lib/screens/tasklist_screen.dart index 2e13447..5417237 100644 --- a/lib/screens/tasklist_screen.dart +++ b/lib/screens/tasklist_screen.dart @@ -1,9 +1,13 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:taskly/models/tag.dart'; import 'package:taskly/models/task.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; +import 'package:taskly/providers/tags_provider.dart'; import 'package:taskly/screens/task_box.dart'; import 'package:taskly/storage/task_storage.dart'; import 'package:taskly/utils/date_utils.dart'; +import 'package:taskly/widgets/spacing.dart'; class TaskListScreen extends StatefulWidget { final List tasks; @@ -29,84 +33,100 @@ class _TaskListScreenState extends State { @override Widget build(BuildContext context) { - return ListView.builder( - itemCount: widget.tasks.length, - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemBuilder: (context, index) { - final task = widget.tasks[index]; - return Slidable( - endActionPane: ActionPane( - motion: const StretchMotion(), - children: [ - SlidableAction( - onPressed: (context) async { - setState(() { - widget.tasks.removeAt(index); - }); - await TaskStorage.saveTasks(widget.tasks); - }, - icon: Icons.delete, - foregroundColor: Colors.red, - ), - ], - ), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8), - child: Container( - color: task.color.withOpacity(0.2), - child: ListTile( - onTap: () { - showDialog( - context: context, - builder: (context) => TaskBoxWidget( - task: task, - onEdit: () => widget.onEdit(index), - onStart: () => widget.onStart(index), - onDelete: () async { - setState(() { - deletedTask = widget.tasks[index]; - deletedIndex = index; - widget.tasks.removeAt(index); - }); - Navigator.of(context) - .pop(); // Close the dialog after deletion + return Consumer( + builder: (context, value, child) => ListView.builder( + itemCount: widget.tasks.length, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemBuilder: (context, index) { + final task = widget.tasks[index]; + + List tags = value.allTags + .where( + (alltag) => task.tags.any((element) => element == alltag.id)) + .toList(); + + return Slidable( + endActionPane: ActionPane( + motion: const StretchMotion(), + children: [ + SlidableAction( + onPressed: (context) async { + setState(() { + widget.tasks.removeAt(index); + }); + await TaskStorage.saveTasks(widget.tasks); + }, + icon: Icons.delete, + foregroundColor: Colors.red, + ), + ], + ), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8), + child: Card( + elevation: 0, + color: task.color.withOpacity(0.2), + margin: const EdgeInsets.all(0), + child: ListTile( + onTap: () { + showDialog( + context: context, + builder: (context) => TaskBoxWidget( + task: task, + onEdit: () => widget.onEdit(index), + onStart: () => widget.onStart(index), + onDelete: () async { + setState(() { + deletedTask = widget.tasks[index]; + deletedIndex = index; + widget.tasks.removeAt(index); + }); + Navigator.of(context) + .pop(); // Close the dialog after deletion - ScaffoldMessenger.of(context) - .showSnackBar( - SnackBar( - content: const Text("Deleted accidentally?"), - action: SnackBarAction( - label: "Undo", - onPressed: () { - widget.tasks - .insert(deletedIndex!, deletedTask!); - setState(() {}); - }, + ScaffoldMessenger.of(context) + .showSnackBar( + SnackBar( + content: const Text("Deleted accidentally?"), + action: SnackBarAction( + label: "Undo", + onPressed: () { + widget.tasks + .insert(deletedIndex!, deletedTask!); + setState(() {}); + }, + ), ), - ), - ) - .closed - .then( - (value) async { - if (value != SnackBarClosedReason.action) { - await TaskStorage.saveTasks(widget.tasks); - } - }, - ); - }, - onClose: () => Navigator.of(context).pop(), - ), - ); - }, - title: Text( - task.title, - style: TextStyle( - decoration: - task.isCompleted ? TextDecoration.lineThrough : null, + ) + .closed + .then( + (value) async { + if (value != SnackBarClosedReason.action) { + await TaskStorage.saveTasks(widget.tasks); + } + }, + ); + }, + onClose: () => Navigator.of(context).pop(), + ), + ); + }, + title: Row( + children: [ + Text( + task.title, + style: TextStyle( + decoration: task.isCompleted + ? TextDecoration.lineThrough + : null, + ), + ), + const SizedBox(width: 4), + if (task.isRecurring) const Icon(Icons.repeat_rounded) + ], ), - ), - subtitle: Column( + subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( @@ -114,38 +134,55 @@ class _TaskListScreenState extends State { ? '${task.description.substring(0, 30)}...' : task.description, ), - Row(children: [ - if (task.hasDeadline) - Text( - 'Deadline: ${MyDateUtils.getFormattedDate(task.deadline)}'), - if (task.hasDeadline && - task.deadline.isBefore(DateTime.now()) && - !task.isCompleted) - const Icon( - Icons.warning, - color: Colors.red, - ), - ]), - ]), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - onPressed: () => widget.onEdit(index), - icon: const Icon(Icons.edit), - ), - const SizedBox(width: 5), - Checkbox( - value: task.isCompleted, - onChanged: (value) => widget.onToggle(index, value), - ), - ], + Row( + children: [ + if (task.hasDeadline) + Text( + 'Deadline: ${MyDateUtils.getFormattedDate(task.deadline)}'), + if (task.hasDeadline && + task.deadline.isBefore(DateTime.now()) && + !task.isCompleted) + const Icon( + Icons.warning, + color: Colors.red, + ), + ], + ), + const Spacing(), + Wrap( + spacing: 5, + runSpacing: 5, + children: tags + .map( + (e) => Badge( + backgroundColor: e.color, + label: Text(e.title), + ), + ) + .toList(), + ) + ], + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: () => widget.onEdit(index), + icon: const Icon(Icons.edit), + ), + const SizedBox(width: 5), + Checkbox( + value: task.isCompleted, + onChanged: (value) => widget.onToggle(index, value), + ), + ], + ), ), ), ), - ), - ); - }, + ); + }, + ), ); } } diff --git a/lib/storage/tags_storage.dart b/lib/storage/tags_storage.dart new file mode 100644 index 0000000..ef4732c --- /dev/null +++ b/lib/storage/tags_storage.dart @@ -0,0 +1,28 @@ +import 'dart:convert'; + +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:taskly/models/tag.dart'; + +class TagsStorage { + static const _key = "tags"; + + static Future saveTags(List tags) async { + SharedPreferences sharedPreferences = await SharedPreferences.getInstance(); + List str = tags.map((e) => jsonEncode(e.toJson())).toList(); + sharedPreferences.setStringList(_key, str); + } + + static Future> loadTags() async { + SharedPreferences sharedPreferences = await SharedPreferences.getInstance(); + List? str = sharedPreferences.getStringList(_key); + if (str != null) { + return str.map((e) => Tag.fromJson(jsonDecode(e))).toList(); + } + return []; + } + + static Future> getTagsFromIds(List ids) async { + List tags = await loadTags(); + return tags.where((element) => ids.contains(element.id)).toList(); + } +} diff --git a/lib/widgets/repeat_select_card.dart b/lib/widgets/repeat_select_card.dart new file mode 100644 index 0000000..b95dda3 --- /dev/null +++ b/lib/widgets/repeat_select_card.dart @@ -0,0 +1,130 @@ +import 'package:choice/choice.dart'; +import 'package:flutter/material.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:taskly/enums/repeat_interval.dart'; + +class RepeatSelectCard extends StatefulWidget { + final int? repeatInterval; + const RepeatSelectCard({super.key, this.repeatInterval}); + + @override + State createState() => _RepeatSelectCardState(); +} + +class _RepeatSelectCardState extends State { + final repeatIntervals = [ + RepeatInterval.daily, + RepeatInterval.weekly, + RepeatInterval.monthly + ]; + + RepeatInterval? selected; + final TextEditingController controller = TextEditingController(); + + @override + void initState() { + super.initState(); + if (widget.repeatInterval != null) { + for (var element in repeatIntervals) { + if (element.days == widget.repeatInterval) { + setSelectedValue(element); + return; + } + } + + controller.text = "${widget.repeatInterval}"; + } + } + + void setSelectedValue(RepeatInterval? value) { + setState(() => selected = value); + } + + @override + Widget build(BuildContext context) { + return Dialog( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "Select predefined interval", + style: Theme.of(context).textTheme.bodyLarge, + ), + Choice.inline( + itemCount: repeatIntervals.length, + clearable: true, + multiple: false, + value: ChoiceSingle.value(selected), + onChanged: ChoiceSingle.onChanged(setSelectedValue), + itemBuilder: (state, index) => ChoiceChip( + label: Text(repeatIntervals[index].getString()), + selected: state.selected(repeatIntervals[index]), + onSelected: state.onSelected(repeatIntervals[index]), + ), + listBuilder: ChoiceList.createScrollable( + spacing: 10, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 16, + ), + ), + ), + const Row( + children: [ + Expanded(child: Divider()), + Text("OR"), + Expanded(child: Divider()), + ], + ), + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 8.0, vertical: 16), + child: TextField( + enabled: selected == null, + controller: controller, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + hintText: "Custom days for repeat interval", + ), + ), + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + ElevatedButton( + onPressed: () => Navigator.pop(context), + child: const Text("Reset"), + ), + ElevatedButton( + onPressed: () { + if (selected != null) { + Navigator.pop(context, selected!.days); + } else { + int? custom = int.tryParse(controller.text); + if (controller.text.isEmpty || custom == null) { + Fluttertoast.showToast( + msg: "Select a repeat interval!"); + } else { + if (custom <= 0) { + Fluttertoast.showToast( + msg: + "Repeat interval must be greater than 0 days!"); + } else { + Navigator.pop(context, custom); + } + } + } + }, + child: const Text("Submit"), + ), + ], + ) + ], + ), + ), + ); + } +} diff --git a/lib/widgets/tags_card.dart b/lib/widgets/tags_card.dart new file mode 100644 index 0000000..67a4c8e --- /dev/null +++ b/lib/widgets/tags_card.dart @@ -0,0 +1,133 @@ +import 'package:choice/choice.dart'; +import 'package:flutter/material.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:provider/provider.dart'; +import 'package:taskly/models/tag.dart'; +import 'package:taskly/providers/tags_provider.dart'; +import 'package:taskly/storage/tags_storage.dart'; +import 'package:taskly/widgets/spacing.dart'; + +class TagsCard extends StatefulWidget { + final List tagIds; + TagsCard({super.key, List? tags}) : tagIds = tags ?? []; + + @override + State createState() => _TagsCardState(); +} + +class _TagsCardState extends State { + late List allTags = []; + late List currentShowing = []; + late List selected = []; + + final TextEditingController controller = TextEditingController(); + + @override + void initState() { + super.initState(); + _fetchTags(); + } + + void _fetchTags() { + allTags = Provider.of(context, listen: false).allTags; + currentShowing = allTags; + + if (widget.tagIds.isNotEmpty) { + selected = allTags + .where((tag) => widget.tagIds.any((element) => element == tag.id)) + .toList(); + } + setState(() {}); + } + + void setSelected(List tags) => setState(() { + selected = tags; + }); + + void onSubmit() { + Navigator.pop(context, selected); + } + + void onCreate() async { + if (controller.text.isEmpty) { + Fluttertoast.showToast(msg: "Enter title for the tag!"); + return; + } + if (allTags.any((element) => element.title == controller.text)) { + Fluttertoast.showToast(msg: "A tag with same title exists"); + return; + } + + Tag tag = Tag(title: controller.text); + await TagsStorage.saveTags(allTags..add(tag)); + if (mounted) { + Provider.of(context, listen: false).updateTags(allTags); + Navigator.pop(context, selected..add(tag)); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + persistentFooterButtons: [ + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: onSubmit, + child: const Text("Submit"), + ), + ), + ], + body: Padding( + padding: const EdgeInsets.all(8.0), + child: ListView( + children: [ + SearchBar( + controller: controller, + hintText: "Start typing tag title...", + onChanged: (value) { + setState(() { + currentShowing = allTags + .where((element) => + element.title.toLowerCase().contains(value)) + .toList(); + }); + }, + ), + const Spacing(), + if (controller.text.isNotEmpty) + ElevatedButton.icon( + onPressed: onCreate, + label: const Text("Create Tag"), + icon: const Icon(Icons.add), + ), + Choice.inline( + itemCount: currentShowing.length, + clearable: true, + multiple: true, + value: selected, + onChanged: setSelected, + listBuilder: ChoiceList.createWrapped( + spacing: 10, + runSpacing: 10, + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 25, + ), + ), + itemBuilder: (state, i) { + return ChoiceChip( + selected: state.selected(currentShowing[i]), + onSelected: state.onSelected(currentShowing[i]), + label: Text(currentShowing[i].title), + avatar: + CircleAvatar(backgroundColor: currentShowing[i].color), + ); + }, + ), + ], + ), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 8b63e05..bbe233f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -73,6 +73,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + badges: + dependency: "direct main" + description: + name: badges + sha256: a7b6bbd60dce418df0db3058b53f9d083c22cdb5132a052145dc267494df0b84 + url: "https://pub.dev" + source: hosted + version: "3.1.2" boolean_selector: dependency: transitive description: @@ -89,6 +97,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + choice: + dependency: "direct main" + description: + name: choice + sha256: "52d07065e8056beba5b26cff7786134cbfa24927b1f5bf60a05d50058597b2d9" + url: "https://pub.dev" + source: hosted + version: "2.3.2" circular_countdown_timer: dependency: "direct main" description: @@ -113,6 +129,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.18.0" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670" + url: "https://pub.dev" + source: hosted + version: "0.3.4+2" crypto: dependency: transitive description: @@ -121,6 +145,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.6" + csv: + dependency: "direct main" + description: + name: csv + sha256: c6aa2679b2a18cb57652920f674488d89712efaf4d3fdf2e537215b35fc19d6c + url: "https://pub.dev" + source: hosted + version: "6.0.0" cupertino_icons: dependency: "direct main" description: @@ -169,6 +201,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: c904b4ab56d53385563c7c39d8e9fa9af086f91495dfc48717ad84a42c3cf204 + url: "https://pub.dev" + source: hosted + version: "8.1.7" fixnum: dependency: transitive description: @@ -254,6 +294,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.2" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "615a505aef59b151b46bbeef55b36ce2b6ed299d160c51d84281946f0aa0ce0e" + url: "https://pub.dev" + source: hosted + version: "2.0.24" flutter_slidable: dependency: "direct main" description: @@ -701,6 +749,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + win32: + dependency: transitive + description: + name: win32 + sha256: "154360849a56b7b67331c21f09a386562d88903f90a1099c5987afc1912e1f29" + url: "https://pub.dev" + source: hosted + version: "5.10.0" xdg_directories: dependency: transitive description: @@ -719,4 +775,4 @@ packages: version: "6.5.0" sdks: dart: ">=3.5.0 <4.0.0" - flutter: ">=3.24.0" \ No newline at end of file + flutter: ">=3.24.0" diff --git a/pubspec.yaml b/pubspec.yaml index 0ef4586..2e9ef4b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -51,7 +51,8 @@ dependencies: duration_picker: ^1.2.0 circular_countdown_timer: ^0.2.4 flutter_confetti: ^0.3.4 - + choice: ^2.3.2 + badges: ^3.1.2 dev_dependencies: flutter_test: