diff --git a/ios/Podfile.lock b/ios/Podfile.lock new file mode 100644 index 0000000..d5e6ab1 --- /dev/null +++ b/ios/Podfile.lock @@ -0,0 +1,23 @@ +PODS: + - Flutter (1.0.0) + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + +DEPENDENCIES: + - Flutter (from `Flutter`) + - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) + +EXTERNAL SOURCES: + Flutter: + :path: Flutter + shared_preferences_foundation: + :path: ".symlinks/plugins/shared_preferences_foundation/darwin" + +SPEC CHECKSUMS: + Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 + shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 + +PODFILE CHECKSUM: 4305caec6b40dde0ae97be1573c53de1882a07e5 + +COCOAPODS: 1.16.2 diff --git a/lib/core/di/injection.config.dart b/lib/core/di/injection.config.dart index 2f12433..b930b7d 100644 --- a/lib/core/di/injection.config.dart +++ b/lib/core/di/injection.config.dart @@ -37,12 +37,15 @@ extension GetItInjectableX on _i174.GetIt { gh.factory<_i484.TodoRemoteDataSource>( () => _i484.TodoRemoteDataSource(gh<_i519.Client>()), ); - gh.factory<_i131.TodoRepository>( - () => _i131.TodoRepository(gh<_i484.TodoRemoteDataSource>()), - ); gh.factory<_i137.TodoLocalDataSource>( () => _i137.TodoLocalDataSource(gh<_i460.SharedPreferences>()), ); + gh.factory<_i131.TodoRepository>( + () => _i131.TodoRepository( + gh<_i484.TodoRemoteDataSource>(), + gh<_i137.TodoLocalDataSource>(), + ), + ); return this; } } diff --git a/lib/features/todo/data/datasources/todo_local_datasource.dart b/lib/features/todo/data/datasources/todo_local_datasource.dart index 78a80e3..980be8c 100644 --- a/lib/features/todo/data/datasources/todo_local_datasource.dart +++ b/lib/features/todo/data/datasources/todo_local_datasource.dart @@ -9,4 +9,25 @@ class TodoLocalDataSource { final String _todoKey = 'todos'; TodoLocalDataSource(this.sharedPreferences); + + /// Toggles the favourite status of a todo by its [id]. + /// Returns the new favourite status. + Future toggleFavourite(String id) async { + final favourites = sharedPreferences.getStringList('favourite_todos') ?? []; + if (favourites.contains(id)) { + favourites.remove(id); + await sharedPreferences.setStringList('favourite_todos', favourites); + return false; + } else { + favourites.add(id); + await sharedPreferences.setStringList('favourite_todos', favourites); + return true; + } + } + + /// Checks if a todo with [id] is favourited. + bool isFavourite(String id) { + final favourites = sharedPreferences.getStringList('favourite_todos') ?? []; + return favourites.contains(id); + } } diff --git a/lib/features/todo/data/datasources/todo_remote_datasource.dart b/lib/features/todo/data/datasources/todo_remote_datasource.dart index 6b1878f..6c7b442 100644 --- a/lib/features/todo/data/datasources/todo_remote_datasource.dart +++ b/lib/features/todo/data/datasources/todo_remote_datasource.dart @@ -11,13 +11,13 @@ import '../models/todo_dto.dart'; class TodoRemoteDataSource { final http.Client _client; // TODO: Replace this with the URL we provide you - final String baseUrl = 'https://6825aa0f0f0188d7e72ddd9a.mockapi.io/api/v1'; + final String baseUrl = 'https://683469d6464b49963602b8e8.mockapi.io'; TodoRemoteDataSource(this._client); Future> getTodos() async { try { - final response = await _client.get(Uri.parse('$baseUrl/todos')); + final response = await _client.get(Uri.parse('$baseUrl/todo')); if (response.statusCode == 200) { final List jsonList = json.decode(response.body); @@ -32,14 +32,18 @@ class TodoRemoteDataSource { } } - Future addTodo(String title) async { + Future addTodo(String title, String description) async { try { - final newTodo = {'title': title, 'createdAtSeconds': DateTime.now().millisecondsSinceEpoch ~/ 1000}; + final newTodo = { + 'title': title, + 'description': description, + 'createdAtSeconds': DateTime.now().millisecondsSinceEpoch ~/ 1000, + }; log('Sending todo: ${json.encode(newTodo)}'); final response = await _client.post( - Uri.parse('$baseUrl/todos'), + Uri.parse('$baseUrl/todo'), headers: {'Content-Type': 'application/json'}, body: json.encode(newTodo), ); @@ -61,6 +65,43 @@ class TodoRemoteDataSource { } } + Future toggleTodoCompletion(String todoId, bool isDone) async { + try { + final response = await _client.put( + Uri.parse('$baseUrl/todo/$todoId'), + headers: {'Content-Type': 'application/json'}, + body: json.encode({'isDone': isDone}), + ); + + if (response.statusCode == 201 || response.statusCode == 200) { + final Map responseData = json.decode(response.body); + + // If the API response is not in the expected format, transform it + return TodoDTO.fromJson(responseData); + } else { + throw Exception('Failed to create todo: ${response.statusCode}'); + } + } catch (e) { + log('Error toggling todo completion: $e'); + throw Exception('Failed to toggle todo completion: $e'); + } + } + + Future deleteTodo(String todoId) async { + try { + final response = await _client.delete( + Uri.parse('$baseUrl/todo/$todoId'), + headers: {'Content-Type': 'application/json'}, + ); + if (response.statusCode != 200 && response.statusCode != 204) { + throw Exception('Failed to delete todo: ${response.statusCode}'); + } + } catch (e) { + log('Error deleting todo: $e'); + throw Exception('Failed to delete todo: $e'); + } + } + // For local client-side ID generation String generateId() { return const Uuid().v4(); diff --git a/lib/features/todo/data/models/todo_dto.dart b/lib/features/todo/data/models/todo_dto.dart index 8d9d649..e330f6f 100644 --- a/lib/features/todo/data/models/todo_dto.dart +++ b/lib/features/todo/data/models/todo_dto.dart @@ -10,8 +10,12 @@ class TodoDTO with _$TodoDTO { const factory TodoDTO({ required String id, required String title, + required String description, + required String imageUrl, + required bool isDone, int? createdAtSeconds, }) = _TodoDTO; - factory TodoDTO.fromJson(Map json) => _$TodoDTOFromJson(json); + factory TodoDTO.fromJson(Map json) => + _$TodoDTOFromJson(json); } diff --git a/lib/features/todo/data/models/todo_dto.freezed.dart b/lib/features/todo/data/models/todo_dto.freezed.dart index 752b03e..37bd679 100644 --- a/lib/features/todo/data/models/todo_dto.freezed.dart +++ b/lib/features/todo/data/models/todo_dto.freezed.dart @@ -23,6 +23,9 @@ TodoDTO _$TodoDTOFromJson(Map json) { mixin _$TodoDTO { String get id => throw _privateConstructorUsedError; String get title => throw _privateConstructorUsedError; + String get description => throw _privateConstructorUsedError; + String get imageUrl => throw _privateConstructorUsedError; + bool get isDone => throw _privateConstructorUsedError; int? get createdAtSeconds => throw _privateConstructorUsedError; /// Serializes this TodoDTO to a JSON map. @@ -39,7 +42,14 @@ abstract class $TodoDTOCopyWith<$Res> { factory $TodoDTOCopyWith(TodoDTO value, $Res Function(TodoDTO) then) = _$TodoDTOCopyWithImpl<$Res, TodoDTO>; @useResult - $Res call({String id, String title, int? createdAtSeconds}); + $Res call({ + String id, + String title, + String description, + String imageUrl, + bool isDone, + int? createdAtSeconds, + }); } /// @nodoc @@ -59,6 +69,9 @@ class _$TodoDTOCopyWithImpl<$Res, $Val extends TodoDTO> $Res call({ Object? id = null, Object? title = null, + Object? description = null, + Object? imageUrl = null, + Object? isDone = null, Object? createdAtSeconds = freezed, }) { return _then( @@ -73,6 +86,21 @@ class _$TodoDTOCopyWithImpl<$Res, $Val extends TodoDTO> ? _value.title : title // ignore: cast_nullable_to_non_nullable as String, + description: + null == description + ? _value.description + : description // ignore: cast_nullable_to_non_nullable + as String, + imageUrl: + null == imageUrl + ? _value.imageUrl + : imageUrl // ignore: cast_nullable_to_non_nullable + as String, + isDone: + null == isDone + ? _value.isDone + : isDone // ignore: cast_nullable_to_non_nullable + as bool, createdAtSeconds: freezed == createdAtSeconds ? _value.createdAtSeconds @@ -92,7 +120,14 @@ abstract class _$$TodoDTOImplCopyWith<$Res> implements $TodoDTOCopyWith<$Res> { ) = __$$TodoDTOImplCopyWithImpl<$Res>; @override @useResult - $Res call({String id, String title, int? createdAtSeconds}); + $Res call({ + String id, + String title, + String description, + String imageUrl, + bool isDone, + int? createdAtSeconds, + }); } /// @nodoc @@ -111,6 +146,9 @@ class __$$TodoDTOImplCopyWithImpl<$Res> $Res call({ Object? id = null, Object? title = null, + Object? description = null, + Object? imageUrl = null, + Object? isDone = null, Object? createdAtSeconds = freezed, }) { return _then( @@ -125,6 +163,21 @@ class __$$TodoDTOImplCopyWithImpl<$Res> ? _value.title : title // ignore: cast_nullable_to_non_nullable as String, + description: + null == description + ? _value.description + : description // ignore: cast_nullable_to_non_nullable + as String, + imageUrl: + null == imageUrl + ? _value.imageUrl + : imageUrl // ignore: cast_nullable_to_non_nullable + as String, + isDone: + null == isDone + ? _value.isDone + : isDone // ignore: cast_nullable_to_non_nullable + as bool, createdAtSeconds: freezed == createdAtSeconds ? _value.createdAtSeconds @@ -141,6 +194,9 @@ class _$TodoDTOImpl extends _TodoDTO { const _$TodoDTOImpl({ required this.id, required this.title, + required this.description, + required this.imageUrl, + required this.isDone, this.createdAtSeconds, }) : super._(); @@ -152,11 +208,17 @@ class _$TodoDTOImpl extends _TodoDTO { @override final String title; @override + final String description; + @override + final String imageUrl; + @override + final bool isDone; + @override final int? createdAtSeconds; @override String toString() { - return 'TodoDTO(id: $id, title: $title, createdAtSeconds: $createdAtSeconds)'; + return 'TodoDTO(id: $id, title: $title, description: $description, imageUrl: $imageUrl, isDone: $isDone, createdAtSeconds: $createdAtSeconds)'; } @override @@ -166,13 +228,26 @@ class _$TodoDTOImpl extends _TodoDTO { other is _$TodoDTOImpl && (identical(other.id, id) || other.id == id) && (identical(other.title, title) || other.title == title) && + (identical(other.description, description) || + other.description == description) && + (identical(other.imageUrl, imageUrl) || + other.imageUrl == imageUrl) && + (identical(other.isDone, isDone) || other.isDone == isDone) && (identical(other.createdAtSeconds, createdAtSeconds) || other.createdAtSeconds == createdAtSeconds)); } @JsonKey(includeFromJson: false, includeToJson: false) @override - int get hashCode => Object.hash(runtimeType, id, title, createdAtSeconds); + int get hashCode => Object.hash( + runtimeType, + id, + title, + description, + imageUrl, + isDone, + createdAtSeconds, + ); /// Create a copy of TodoDTO /// with the given fields replaced by the non-null parameter values. @@ -192,6 +267,9 @@ abstract class _TodoDTO extends TodoDTO { const factory _TodoDTO({ required final String id, required final String title, + required final String description, + required final String imageUrl, + required final bool isDone, final int? createdAtSeconds, }) = _$TodoDTOImpl; const _TodoDTO._() : super._(); @@ -203,6 +281,12 @@ abstract class _TodoDTO extends TodoDTO { @override String get title; @override + String get description; + @override + String get imageUrl; + @override + bool get isDone; + @override int? get createdAtSeconds; /// Create a copy of TodoDTO diff --git a/lib/features/todo/data/models/todo_dto.g.dart b/lib/features/todo/data/models/todo_dto.g.dart index bada3c6..0a23684 100644 --- a/lib/features/todo/data/models/todo_dto.g.dart +++ b/lib/features/todo/data/models/todo_dto.g.dart @@ -10,6 +10,9 @@ _$TodoDTOImpl _$$TodoDTOImplFromJson(Map json) => _$TodoDTOImpl( id: json['id'] as String, title: json['title'] as String, + description: json['description'] as String, + imageUrl: json['imageUrl'] as String, + isDone: json['isDone'] as bool, createdAtSeconds: (json['createdAtSeconds'] as num?)?.toInt(), ); @@ -17,5 +20,8 @@ Map _$$TodoDTOImplToJson(_$TodoDTOImpl instance) => { 'id': instance.id, 'title': instance.title, + 'description': instance.description, + 'imageUrl': instance.imageUrl, + 'isDone': instance.isDone, 'createdAtSeconds': instance.createdAtSeconds, }; diff --git a/lib/features/todo/data/repositories/todo_repository.dart b/lib/features/todo/data/repositories/todo_repository.dart index 49e63b0..7e9c736 100644 --- a/lib/features/todo/data/repositories/todo_repository.dart +++ b/lib/features/todo/data/repositories/todo_repository.dart @@ -1,5 +1,6 @@ import 'dart:developer'; +import 'package:flutter_todo_workshop/features/todo/data/datasources/todo_local_datasource.dart'; import 'package:injectable/injectable.dart'; import 'package:uuid/uuid.dart'; @@ -10,8 +11,9 @@ import '../models/todo_dto.dart'; @injectable class TodoRepository { final TodoRemoteDataSource remoteDataSource; + final TodoLocalDataSource localDataSource; - TodoRepository(this.remoteDataSource); + TodoRepository(this.remoteDataSource, this.localDataSource); // Get todos from remote data source only Future> getTodos() async { @@ -23,7 +25,9 @@ class TodoRepository { final todos = remoteDtos.map(_mapDtoToModel).toList(); // Sort todos by creation date (newest first) - todos.sort((a, b) => b.effectiveCreatedAt.compareTo(a.effectiveCreatedAt)); + todos.sort( + (a, b) => b.effectiveCreatedAt.compareTo(a.effectiveCreatedAt), + ); return todos; } catch (e) { @@ -34,27 +38,86 @@ class TodoRepository { } // Add a new todo remotely - Future addTodo(String title) async { + Future addTodo(String title, String description) async { try { // Add todo remotely - await remoteDataSource.addTodo(title); + await remoteDataSource.addTodo(title, description); } catch (e) { log('Error adding remote todo: $e'); } } + // Toggle the completion status of a todo + Future toggleTodoCompletion(String todoId, bool isDone) async { + try { + // Toggle completion status remotely + log('Toggling todo completion: $todoId, isDone: $isDone'); + await remoteDataSource.toggleTodoCompletion(todoId, isDone); + } catch (e) { + log('Error toggling todo completion: $e'); + } + } + + Future toggleTodoFavourite(String todoId, bool isFavourited) async { + try { + // Toggle favourite status locally + final newStatus = await localDataSource.toggleFavourite(todoId); + log('Todo $todoId favourite status changed to: $newStatus'); + } catch (e) { + log('Error toggling todo favourite status: $e'); + } + } + + // Check if a todo is favourited + Future isTodoFavourited(String todoId) async { + try { + // Check favourite status locally + final isFavourite = localDataSource.isFavourite(todoId); + return isFavourite; + } catch (e) { + log('Error checking todo favourite status: $e'); + return false; + } + } + + // Delete a todo remotely + Future deleteTodo(String todoId) async { + try { + await remoteDataSource.deleteTodo(todoId); + } catch (e) { + log('Error deleting todo: $e'); + // Optionally rethrow or handle error + } + } + // Helper method to map DTOs to domain models TodoModel _mapDtoToModel(TodoDTO dto) { try { DateTime? createdAt; if (dto.createdAtSeconds != null) { - createdAt = DateTime.fromMillisecondsSinceEpoch(dto.createdAtSeconds! * 1000); + createdAt = DateTime.fromMillisecondsSinceEpoch( + dto.createdAtSeconds! * 1000, + ); } - return TodoModel(id: dto.id, title: dto.title, createdAt: createdAt); + return TodoModel( + id: dto.id, + title: dto.title, + description: dto.description, + imageUrl: dto.imageUrl, + isDone: dto.isDone, + createdAt: createdAt, + ); } catch (e) { log('Error mapping DTO to model: $e'); - return TodoModel(id: const Uuid().v4(), title: 'Unknown title', createdAt: DateTime.now()); + return TodoModel( + id: const Uuid().v4(), + title: 'Unknown title', + description: 'Unknown description', + imageUrl: 'no image', + isDone: false, + createdAt: DateTime.now(), + ); } } } diff --git a/lib/features/todo/domain/models/todo_model.dart b/lib/features/todo/domain/models/todo_model.dart index 606a711..64238e2 100644 --- a/lib/features/todo/domain/models/todo_model.dart +++ b/lib/features/todo/domain/models/todo_model.dart @@ -8,8 +8,14 @@ part 'todo_model.g.dart'; class TodoModel with _$TodoModel { const TodoModel._(); - const factory TodoModel({required String id, required String title, DateTime? createdAt}) = - _TodoModel; + const factory TodoModel({ + required String id, + required String title, + required String description, + required String imageUrl, + required bool isDone, + DateTime? createdAt, + }) = _TodoModel; DateTime get effectiveCreatedAt => createdAt ?? DateTime.now(); @@ -18,5 +24,6 @@ class TodoModel with _$TodoModel { return dateFormat.format(effectiveCreatedAt); } - factory TodoModel.fromJson(Map json) => _$TodoModelFromJson(json); + factory TodoModel.fromJson(Map json) => + _$TodoModelFromJson(json); } diff --git a/lib/features/todo/domain/models/todo_model.freezed.dart b/lib/features/todo/domain/models/todo_model.freezed.dart index 6052b7a..b9bd72c 100644 --- a/lib/features/todo/domain/models/todo_model.freezed.dart +++ b/lib/features/todo/domain/models/todo_model.freezed.dart @@ -23,6 +23,9 @@ TodoModel _$TodoModelFromJson(Map json) { mixin _$TodoModel { String get id => throw _privateConstructorUsedError; String get title => throw _privateConstructorUsedError; + String get description => throw _privateConstructorUsedError; + String get imageUrl => throw _privateConstructorUsedError; + bool get isDone => throw _privateConstructorUsedError; DateTime? get createdAt => throw _privateConstructorUsedError; /// Serializes this TodoModel to a JSON map. @@ -40,7 +43,14 @@ abstract class $TodoModelCopyWith<$Res> { factory $TodoModelCopyWith(TodoModel value, $Res Function(TodoModel) then) = _$TodoModelCopyWithImpl<$Res, TodoModel>; @useResult - $Res call({String id, String title, DateTime? createdAt}); + $Res call({ + String id, + String title, + String description, + String imageUrl, + bool isDone, + DateTime? createdAt, + }); } /// @nodoc @@ -60,6 +70,9 @@ class _$TodoModelCopyWithImpl<$Res, $Val extends TodoModel> $Res call({ Object? id = null, Object? title = null, + Object? description = null, + Object? imageUrl = null, + Object? isDone = null, Object? createdAt = freezed, }) { return _then( @@ -74,6 +87,21 @@ class _$TodoModelCopyWithImpl<$Res, $Val extends TodoModel> ? _value.title : title // ignore: cast_nullable_to_non_nullable as String, + description: + null == description + ? _value.description + : description // ignore: cast_nullable_to_non_nullable + as String, + imageUrl: + null == imageUrl + ? _value.imageUrl + : imageUrl // ignore: cast_nullable_to_non_nullable + as String, + isDone: + null == isDone + ? _value.isDone + : isDone // ignore: cast_nullable_to_non_nullable + as bool, createdAt: freezed == createdAt ? _value.createdAt @@ -94,7 +122,14 @@ abstract class _$$TodoModelImplCopyWith<$Res> ) = __$$TodoModelImplCopyWithImpl<$Res>; @override @useResult - $Res call({String id, String title, DateTime? createdAt}); + $Res call({ + String id, + String title, + String description, + String imageUrl, + bool isDone, + DateTime? createdAt, + }); } /// @nodoc @@ -113,6 +148,9 @@ class __$$TodoModelImplCopyWithImpl<$Res> $Res call({ Object? id = null, Object? title = null, + Object? description = null, + Object? imageUrl = null, + Object? isDone = null, Object? createdAt = freezed, }) { return _then( @@ -127,6 +165,21 @@ class __$$TodoModelImplCopyWithImpl<$Res> ? _value.title : title // ignore: cast_nullable_to_non_nullable as String, + description: + null == description + ? _value.description + : description // ignore: cast_nullable_to_non_nullable + as String, + imageUrl: + null == imageUrl + ? _value.imageUrl + : imageUrl // ignore: cast_nullable_to_non_nullable + as String, + isDone: + null == isDone + ? _value.isDone + : isDone // ignore: cast_nullable_to_non_nullable + as bool, createdAt: freezed == createdAt ? _value.createdAt @@ -140,8 +193,14 @@ class __$$TodoModelImplCopyWithImpl<$Res> /// @nodoc @JsonSerializable() class _$TodoModelImpl extends _TodoModel { - const _$TodoModelImpl({required this.id, required this.title, this.createdAt}) - : super._(); + const _$TodoModelImpl({ + required this.id, + required this.title, + required this.description, + required this.imageUrl, + required this.isDone, + this.createdAt, + }) : super._(); factory _$TodoModelImpl.fromJson(Map json) => _$$TodoModelImplFromJson(json); @@ -151,11 +210,17 @@ class _$TodoModelImpl extends _TodoModel { @override final String title; @override + final String description; + @override + final String imageUrl; + @override + final bool isDone; + @override final DateTime? createdAt; @override String toString() { - return 'TodoModel(id: $id, title: $title, createdAt: $createdAt)'; + return 'TodoModel(id: $id, title: $title, description: $description, imageUrl: $imageUrl, isDone: $isDone, createdAt: $createdAt)'; } @override @@ -165,13 +230,26 @@ class _$TodoModelImpl extends _TodoModel { other is _$TodoModelImpl && (identical(other.id, id) || other.id == id) && (identical(other.title, title) || other.title == title) && + (identical(other.description, description) || + other.description == description) && + (identical(other.imageUrl, imageUrl) || + other.imageUrl == imageUrl) && + (identical(other.isDone, isDone) || other.isDone == isDone) && (identical(other.createdAt, createdAt) || other.createdAt == createdAt)); } @JsonKey(includeFromJson: false, includeToJson: false) @override - int get hashCode => Object.hash(runtimeType, id, title, createdAt); + int get hashCode => Object.hash( + runtimeType, + id, + title, + description, + imageUrl, + isDone, + createdAt, + ); /// Create a copy of TodoModel /// with the given fields replaced by the non-null parameter values. @@ -191,6 +269,9 @@ abstract class _TodoModel extends TodoModel { const factory _TodoModel({ required final String id, required final String title, + required final String description, + required final String imageUrl, + required final bool isDone, final DateTime? createdAt, }) = _$TodoModelImpl; const _TodoModel._() : super._(); @@ -203,6 +284,12 @@ abstract class _TodoModel extends TodoModel { @override String get title; @override + String get description; + @override + String get imageUrl; + @override + bool get isDone; + @override DateTime? get createdAt; /// Create a copy of TodoModel diff --git a/lib/features/todo/domain/models/todo_model.g.dart b/lib/features/todo/domain/models/todo_model.g.dart index 6512f96..d9f7a1a 100644 --- a/lib/features/todo/domain/models/todo_model.g.dart +++ b/lib/features/todo/domain/models/todo_model.g.dart @@ -10,6 +10,9 @@ _$TodoModelImpl _$$TodoModelImplFromJson(Map json) => _$TodoModelImpl( id: json['id'] as String, title: json['title'] as String, + description: json['description'] as String, + imageUrl: json['imageUrl'] as String, + isDone: json['isDone'] as bool, createdAt: json['createdAt'] == null ? null @@ -20,5 +23,8 @@ Map _$$TodoModelImplToJson(_$TodoModelImpl instance) => { 'id': instance.id, 'title': instance.title, + 'description': instance.description, + 'imageUrl': instance.imageUrl, + 'isDone': instance.isDone, 'createdAt': instance.createdAt?.toIso8601String(), }; diff --git a/lib/features/todo/presentation/cubits/todo_cubit.dart b/lib/features/todo/presentation/cubits/todo_cubit.dart index f1805f4..f429aac 100644 --- a/lib/features/todo/presentation/cubits/todo_cubit.dart +++ b/lib/features/todo/presentation/cubits/todo_cubit.dart @@ -11,20 +11,59 @@ class TodoCubit extends Cubit { TodoCubit(this._repository) : super(TodosLoading()); + Future> _getFavouritedIds() async { + final todos = await _repository.getTodos(); + final ids = {}; + for (final todo in todos) { + if (await _repository.isTodoFavourited(todo.id)) { + ids.add(todo.id); + } + } + return ids; + } + Future loadTodos() async { try { - emit(TodosLoading()); final todos = await _repository.getTodos(); - emit(TodosLoaded(todos: todos)); + final favouritedIds = await _getFavouritedIds(); + emit(TodosLoaded(todos: todos, favouritedIds: favouritedIds)); + } catch (e) { + emit(TodosError(message: e.toString())); + } + } + + Future addTodo(String title, String description) async { + try { + emit(TodosLoading()); + await _repository.addTodo(title, description); + await loadTodos(); + } catch (e) { + emit(TodosError(message: e.toString())); + } + } + + Future toggleTodoCompletion(String todoId, bool isDone) async { + try { + await _repository.toggleTodoCompletion(todoId, isDone); + await loadTodos(); + } catch (e) { + emit(TodosError(message: e.toString())); + } + } + + Future toggleTodoFavourite(String todoId, bool isFavourited) async { + try { + await _repository.toggleTodoFavourite(todoId, isFavourited); + await loadTodos(); } catch (e) { emit(TodosError(message: e.toString())); } } - Future addTodo(String title) async { + Future deleteTodo(String todoId) async { try { emit(TodosLoading()); - await _repository.addTodo(title); + await _repository.deleteTodo(todoId); await loadTodos(); } catch (e) { emit(TodosError(message: e.toString())); diff --git a/lib/features/todo/presentation/cubits/todo_state.dart b/lib/features/todo/presentation/cubits/todo_state.dart index e39b174..e886d2c 100644 --- a/lib/features/todo/presentation/cubits/todo_state.dart +++ b/lib/features/todo/presentation/cubits/todo_state.dart @@ -11,11 +11,12 @@ class TodosLoading extends TodoState {} class TodosLoaded extends TodoState { final List todos; + final Set favouritedIds; - const TodosLoaded({required this.todos}); + const TodosLoaded({required this.todos, required this.favouritedIds}); @override - List get props => [todos]; + List get props => [todos, favouritedIds]; } class TodosError extends TodoState { diff --git a/lib/features/todo/presentation/screens/todo_details_screen.dart b/lib/features/todo/presentation/screens/todo_details_screen.dart new file mode 100644 index 0000000..1aaa46f --- /dev/null +++ b/lib/features/todo/presentation/screens/todo_details_screen.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_todo_workshop/features/todo/presentation/widgets/todo_item_details.dart'; + +import '../cubits/todo_cubit.dart'; +import 'todo_form_screen.dart'; + +class TodoDetailsScreen extends StatelessWidget { + final int index; + + const TodoDetailsScreen({super.key, required this.index}); + + @override + Widget build(BuildContext context) { + // Get a reference to the TodoCubit + final todoCubit = context.read(); + + return Scaffold( + appBar: AppBar(title: const Text('Todo App')), + body: BlocBuilder( + builder: (context, state) { + if (state is TodosLoading) { + return const Center(child: CircularProgressIndicator()); + } else if (state is TodosLoaded) { + final todos = state.todos; + final favouritedIds = state.favouritedIds; + + if (todos.isEmpty) { + return const Center( + child: Text( + 'No todos yet!\nTap the + button to add one.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 18), + ), + ); + } + + final todo = todos[index]; + final isFavourited = favouritedIds.contains(todo.id); + + return TodoItemDetails( + todo: todo, + isFavourited: isFavourited, + onCheckboxChanged: (value) { + todoCubit.toggleTodoCompletion( + todo.id, + value ?? false, + ); + }, + onFavouriteChanged: (value) { + todoCubit.toggleTodoFavourite(todo.id, value); + }, + onDelete: () async { + await todoCubit.deleteTodo(todo.id); + if (context.mounted) Navigator.pop(context); + }, + ); + } else if (state is TodosError) { + return Center( + child: Text( + 'Error: ${state.message}', + style: const TextStyle(color: Colors.red), + ), + ); + } + + return const Center(child: Text('Unknown state')); + }, + ), + floatingActionButton: FloatingActionButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => BlocProvider.value( + value: todoCubit, + child: const TodoFormScreen(), + ), + ), + ); + }, + child: const Icon(Icons.add), + ), + ); + } +} diff --git a/lib/features/todo/presentation/screens/todo_form_screen.dart b/lib/features/todo/presentation/screens/todo_form_screen.dart index cf54e93..8b5fcce 100644 --- a/lib/features/todo/presentation/screens/todo_form_screen.dart +++ b/lib/features/todo/presentation/screens/todo_form_screen.dart @@ -13,16 +13,21 @@ class TodoFormScreen extends StatefulWidget { class _TodoFormScreenState extends State { final _formKey = GlobalKey(); final _titleController = TextEditingController(); + final _descriptionController = TextEditingController(); @override void dispose() { _titleController.dispose(); + _descriptionController.dispose(); super.dispose(); } void _submitForm() { if (_formKey.currentState?.validate() ?? false) { - context.read().addTodo(_titleController.text); + context.read().addTodo( + _titleController.text, + _descriptionController.text, + ); Navigator.pop(context); } } @@ -41,7 +46,10 @@ class _TodoFormScreenState extends State { const SizedBox(height: 16), TextFormField( controller: _titleController, - decoration: const InputDecoration(labelText: 'Title', border: OutlineInputBorder()), + decoration: const InputDecoration( + labelText: 'Title', + border: OutlineInputBorder(), + ), validator: (value) { if (value == null || value.isEmpty) { return 'Please enter a title'; @@ -49,10 +57,21 @@ class _TodoFormScreenState extends State { return null; }, ), + const SizedBox(height: 16), + TextFormField( + controller: _descriptionController, + decoration: const InputDecoration( + labelText: 'Description (optional)', + border: OutlineInputBorder(), + ), + maxLines: 2, + ), const SizedBox(height: 24), ElevatedButton( onPressed: _submitForm, - style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + ), child: const Text('Add Todo'), ), ], diff --git a/lib/features/todo/presentation/screens/todo_list_screen.dart b/lib/features/todo/presentation/screens/todo_list_screen.dart index a366ba1..7da497c 100644 --- a/lib/features/todo/presentation/screens/todo_list_screen.dart +++ b/lib/features/todo/presentation/screens/todo_list_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_todo_workshop/features/todo/presentation/screens/todo_details_screen.dart'; import '../cubits/todo_cubit.dart'; import '../widgets/todo_item.dart'; @@ -21,6 +22,7 @@ class TodoListScreen extends StatelessWidget { return const Center(child: CircularProgressIndicator()); } else if (state is TodosLoaded) { final todos = state.todos; + final favouritedIds = state.favouritedIds; if (todos.isEmpty) { return const Center( @@ -35,11 +37,41 @@ class TodoListScreen extends StatelessWidget { return ListView.builder( itemCount: todos.length, itemBuilder: (context, index) { - return TodoItem(todo: todos[index]); + final todo = todos[index]; + final isFavourited = favouritedIds.contains(todo.id); + return TodoItem( + todo: todo, + onCheckboxChanged: (value) { + todoCubit.toggleTodoCompletion( + todo.id, + value ?? false, + ); + }, + onViewDetails: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => BlocProvider.value( + value: todoCubit, + child: TodoDetailsScreen(index: index), + ), + ), + ); + }, + isFavourited: isFavourited, + onFavouriteChanged: (value) { + todoCubit.toggleTodoFavourite(todo.id, value); + }, + ); }, ); } else if (state is TodosError) { - return Center(child: Text('Error: ${state.message}', style: const TextStyle(color: Colors.red))); + return Center( + child: Text( + 'Error: ${state.message}', + style: const TextStyle(color: Colors.red), + ), + ); } return const Center(child: Text('Unknown state')); @@ -50,7 +82,11 @@ class TodoListScreen extends StatelessWidget { Navigator.push( context, MaterialPageRoute( - builder: (context) => BlocProvider.value(value: todoCubit, child: const TodoFormScreen()), + builder: + (context) => BlocProvider.value( + value: todoCubit, + child: const TodoFormScreen(), + ), ), ); }, diff --git a/lib/features/todo/presentation/widgets/todo_item.dart b/lib/features/todo/presentation/widgets/todo_item.dart index 0930dea..967e6eb 100644 --- a/lib/features/todo/presentation/widgets/todo_item.dart +++ b/lib/features/todo/presentation/widgets/todo_item.dart @@ -4,8 +4,19 @@ import '../../domain/models/todo_model.dart'; class TodoItem extends StatelessWidget { final TodoModel todo; + final ValueChanged onCheckboxChanged; + final VoidCallback onViewDetails; + final bool isFavourited; + final ValueChanged onFavouriteChanged; - const TodoItem({super.key, required this.todo}); + const TodoItem({ + super.key, + required this.todo, + required this.onCheckboxChanged, + required this.onViewDetails, + required this.isFavourited, + required this.onFavouriteChanged, + }); @override Widget build(BuildContext context) { @@ -17,11 +28,69 @@ class TodoItem extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(todo.title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), - const SizedBox(height: 4), - Text( - todo.formattedDate, - style: TextStyle(fontSize: 12, color: Colors.grey[600], fontStyle: FontStyle.italic), + Row( + children: [ + Checkbox( + value: todo.isDone, + onChanged: (_) { + onCheckboxChanged(!todo.isDone); + }, + ), + Expanded( + child: GestureDetector( + onTap: onViewDetails, + child: Text( + todo.title, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + IconButton( + icon: Icon( + isFavourited ? Icons.star : Icons.star_border, + color: isFavourited ? Colors.amber : Colors.grey, + ), + onPressed: () { + onFavouriteChanged(!isFavourited); + }, + tooltip: isFavourited ? 'Unfavourite' : 'Favourite', + ), + ], + ), + GestureDetector( + onTap: onViewDetails, + behavior: HitTestBehavior.translucent, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 4), + Text( + todo.formattedDate, + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + fontStyle: FontStyle.italic, + ), + ), + const SizedBox(height: 8), + Text( + todo.description, + style: const TextStyle(fontSize: 14, color: Colors.black87), + maxLines: 2, + ), + const SizedBox(height: 8), + if (todo.imageUrl.isNotEmpty) + Image.network( + todo.imageUrl, + height: 150, + width: double.infinity, + fit: BoxFit.cover, + ), + ], + ), ), ], ), diff --git a/lib/features/todo/presentation/widgets/todo_item_details.dart b/lib/features/todo/presentation/widgets/todo_item_details.dart new file mode 100644 index 0000000..d58a8cf --- /dev/null +++ b/lib/features/todo/presentation/widgets/todo_item_details.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; + +import '../../domain/models/todo_model.dart'; + +class TodoItemDetails extends StatelessWidget { + final TodoModel todo; + final bool isFavourited; + final ValueChanged onCheckboxChanged; + final ValueChanged onFavouriteChanged; + final VoidCallback? onDelete; + + const TodoItemDetails({ + super.key, + required this.todo, + required this.isFavourited, + required this.onCheckboxChanged, + required this.onFavouriteChanged, + this.onDelete, + }); + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Checkbox( + value: todo.isDone, + onChanged: (_) { + onCheckboxChanged(!todo.isDone); + }, + ), + Expanded( + child: Text( + todo.title, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ), + IconButton( + icon: Icon( + isFavourited ? Icons.star : Icons.star_border, + color: isFavourited ? Colors.amber : Colors.grey, + ), + onPressed: () { + onFavouriteChanged(!isFavourited); + }, + tooltip: isFavourited ? 'Unfavourite' : 'Favourite', + ), + IconButton( + icon: const Icon(Icons.delete, color: Colors.red), + onPressed: onDelete, + tooltip: 'Delete', + ), + ], + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 4), + Text( + todo.formattedDate, + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + fontStyle: FontStyle.italic, + ), + ), + const SizedBox(height: 8), + Text( + todo.description, + style: const TextStyle(fontSize: 14, color: Colors.black87), + ), + const SizedBox(height: 8), + if (todo.imageUrl.isNotEmpty) + Image.network(todo.imageUrl, fit: BoxFit.fitWidth), + ], + ), + ], + ), + ), + ); + } +}