From 830c30006ac5b1b35d7564326e43aafe80f62c57 Mon Sep 17 00:00:00 2001 From: rich Date: Tue, 27 May 2025 11:43:20 +0100 Subject: [PATCH 1/4] get description & imageURL from API and render in UI --- devtools_options.yaml | 3 + ios/Podfile.lock | 23 ++++++ .../xcshareddata/xcschemes/Runner.xcscheme | 2 + .../datasources/todo_remote_datasource.dart | 3 +- lib/features/todo/data/models/todo_dto.dart | 2 + .../todo/data/models/todo_dto.freezed.dart | 69 +++++++++++++++- lib/features/todo/data/models/todo_dto.g.dart | 4 + .../data/repositories/todo_repository.dart | 2 +- .../todo/domain/models/todo_model.dart | 2 +- .../domain/models/todo_model.freezed.dart | 70 ++++++++++++++-- .../todo/domain/models/todo_model.g.dart | 4 + .../todo/presentation/widgets/todo_item.dart | 79 +++++++++++++++++-- pubspec.lock | 16 ++-- 13 files changed, 250 insertions(+), 29 deletions(-) create mode 100644 devtools_options.yaml create mode 100644 ios/Podfile.lock diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: 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/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 15cada4..e3773d4 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -26,6 +26,7 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit" shouldUseLaunchSchemeArgsEnv = "YES"> json) { mixin _$TodoDTO { String get id => throw _privateConstructorUsedError; String get title => throw _privateConstructorUsedError; + String? get description => throw _privateConstructorUsedError; + String? get imageUrl => throw _privateConstructorUsedError; int? get createdAtSeconds => throw _privateConstructorUsedError; /// Serializes this TodoDTO to a JSON map. @@ -39,7 +41,13 @@ 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, + int? createdAtSeconds, + }); } /// @nodoc @@ -59,6 +67,8 @@ class _$TodoDTOCopyWithImpl<$Res, $Val extends TodoDTO> $Res call({ Object? id = null, Object? title = null, + Object? description = freezed, + Object? imageUrl = freezed, Object? createdAtSeconds = freezed, }) { return _then( @@ -73,6 +83,16 @@ class _$TodoDTOCopyWithImpl<$Res, $Val extends TodoDTO> ? _value.title : title // ignore: cast_nullable_to_non_nullable as String, + description: + freezed == description + ? _value.description + : description // ignore: cast_nullable_to_non_nullable + as String?, + imageUrl: + freezed == imageUrl + ? _value.imageUrl + : imageUrl // ignore: cast_nullable_to_non_nullable + as String?, createdAtSeconds: freezed == createdAtSeconds ? _value.createdAtSeconds @@ -92,7 +112,13 @@ 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, + int? createdAtSeconds, + }); } /// @nodoc @@ -111,6 +137,8 @@ class __$$TodoDTOImplCopyWithImpl<$Res> $Res call({ Object? id = null, Object? title = null, + Object? description = freezed, + Object? imageUrl = freezed, Object? createdAtSeconds = freezed, }) { return _then( @@ -125,6 +153,16 @@ class __$$TodoDTOImplCopyWithImpl<$Res> ? _value.title : title // ignore: cast_nullable_to_non_nullable as String, + description: + freezed == description + ? _value.description + : description // ignore: cast_nullable_to_non_nullable + as String?, + imageUrl: + freezed == imageUrl + ? _value.imageUrl + : imageUrl // ignore: cast_nullable_to_non_nullable + as String?, createdAtSeconds: freezed == createdAtSeconds ? _value.createdAtSeconds @@ -141,6 +179,8 @@ class _$TodoDTOImpl extends _TodoDTO { const _$TodoDTOImpl({ required this.id, required this.title, + this.description, + this.imageUrl, this.createdAtSeconds, }) : super._(); @@ -152,11 +192,15 @@ class _$TodoDTOImpl extends _TodoDTO { @override final String title; @override + final String? description; + @override + final String? imageUrl; + @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, createdAtSeconds: $createdAtSeconds)'; } @override @@ -166,13 +210,24 @@ 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.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, + createdAtSeconds, + ); /// Create a copy of TodoDTO /// with the given fields replaced by the non-null parameter values. @@ -192,6 +247,8 @@ abstract class _TodoDTO extends TodoDTO { const factory _TodoDTO({ required final String id, required final String title, + final String? description, + final String? imageUrl, final int? createdAtSeconds, }) = _$TodoDTOImpl; const _TodoDTO._() : super._(); @@ -203,6 +260,10 @@ abstract class _TodoDTO extends TodoDTO { @override String get title; @override + String? get description; + @override + String? get imageUrl; + @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..b04069b 100644 --- a/lib/features/todo/data/models/todo_dto.g.dart +++ b/lib/features/todo/data/models/todo_dto.g.dart @@ -10,6 +10,8 @@ _$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?, createdAtSeconds: (json['createdAtSeconds'] as num?)?.toInt(), ); @@ -17,5 +19,7 @@ Map _$$TodoDTOImplToJson(_$TodoDTOImpl instance) => { 'id': instance.id, 'title': instance.title, + 'description': instance.description, + 'imageUrl': instance.imageUrl, '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..a70cab4 100644 --- a/lib/features/todo/data/repositories/todo_repository.dart +++ b/lib/features/todo/data/repositories/todo_repository.dart @@ -51,7 +51,7 @@ class TodoRepository { 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, createdAt: createdAt); } catch (e) { log('Error mapping DTO to model: $e'); return TodoModel(id: const Uuid().v4(), title: 'Unknown title', 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..656d942 100644 --- a/lib/features/todo/domain/models/todo_model.dart +++ b/lib/features/todo/domain/models/todo_model.dart @@ -8,7 +8,7 @@ part 'todo_model.g.dart'; class TodoModel with _$TodoModel { const TodoModel._(); - const factory TodoModel({required String id, required String title, DateTime? createdAt}) = + const factory TodoModel({required String id, required String title, String? description, String? imageUrl, DateTime? createdAt}) = _TodoModel; DateTime get effectiveCreatedAt => createdAt ?? DateTime.now(); diff --git a/lib/features/todo/domain/models/todo_model.freezed.dart b/lib/features/todo/domain/models/todo_model.freezed.dart index 6052b7a..359be31 100644 --- a/lib/features/todo/domain/models/todo_model.freezed.dart +++ b/lib/features/todo/domain/models/todo_model.freezed.dart @@ -23,6 +23,8 @@ 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; DateTime? get createdAt => throw _privateConstructorUsedError; /// Serializes this TodoModel to a JSON map. @@ -40,7 +42,13 @@ 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, + DateTime? createdAt, + }); } /// @nodoc @@ -60,6 +68,8 @@ class _$TodoModelCopyWithImpl<$Res, $Val extends TodoModel> $Res call({ Object? id = null, Object? title = null, + Object? description = freezed, + Object? imageUrl = freezed, Object? createdAt = freezed, }) { return _then( @@ -74,6 +84,16 @@ class _$TodoModelCopyWithImpl<$Res, $Val extends TodoModel> ? _value.title : title // ignore: cast_nullable_to_non_nullable as String, + description: + freezed == description + ? _value.description + : description // ignore: cast_nullable_to_non_nullable + as String?, + imageUrl: + freezed == imageUrl + ? _value.imageUrl + : imageUrl // ignore: cast_nullable_to_non_nullable + as String?, createdAt: freezed == createdAt ? _value.createdAt @@ -94,7 +114,13 @@ 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, + DateTime? createdAt, + }); } /// @nodoc @@ -113,6 +139,8 @@ class __$$TodoModelImplCopyWithImpl<$Res> $Res call({ Object? id = null, Object? title = null, + Object? description = freezed, + Object? imageUrl = freezed, Object? createdAt = freezed, }) { return _then( @@ -127,6 +155,16 @@ class __$$TodoModelImplCopyWithImpl<$Res> ? _value.title : title // ignore: cast_nullable_to_non_nullable as String, + description: + freezed == description + ? _value.description + : description // ignore: cast_nullable_to_non_nullable + as String?, + imageUrl: + freezed == imageUrl + ? _value.imageUrl + : imageUrl // ignore: cast_nullable_to_non_nullable + as String?, createdAt: freezed == createdAt ? _value.createdAt @@ -140,8 +178,13 @@ 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, + this.description, + this.imageUrl, + this.createdAt, + }) : super._(); factory _$TodoModelImpl.fromJson(Map json) => _$$TodoModelImplFromJson(json); @@ -151,11 +194,15 @@ class _$TodoModelImpl extends _TodoModel { @override final String title; @override + final String? description; + @override + final String? imageUrl; + @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, createdAt: $createdAt)'; } @override @@ -165,13 +212,18 @@ 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.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, createdAt); /// Create a copy of TodoModel /// with the given fields replaced by the non-null parameter values. @@ -191,6 +243,8 @@ abstract class _TodoModel extends TodoModel { const factory _TodoModel({ required final String id, required final String title, + final String? description, + final String? imageUrl, final DateTime? createdAt, }) = _$TodoModelImpl; const _TodoModel._() : super._(); @@ -203,6 +257,10 @@ abstract class _TodoModel extends TodoModel { @override String get title; @override + String? get description; + @override + String? get imageUrl; + @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..1af3b76 100644 --- a/lib/features/todo/domain/models/todo_model.g.dart +++ b/lib/features/todo/domain/models/todo_model.g.dart @@ -10,6 +10,8 @@ _$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?, createdAt: json['createdAt'] == null ? null @@ -20,5 +22,7 @@ Map _$$TodoModelImplToJson(_$TodoModelImpl instance) => { 'id': instance.id, 'title': instance.title, + 'description': instance.description, + 'imageUrl': instance.imageUrl, 'createdAt': instance.createdAt?.toIso8601String(), }; diff --git a/lib/features/todo/presentation/widgets/todo_item.dart b/lib/features/todo/presentation/widgets/todo_item.dart index 0930dea..e11e152 100644 --- a/lib/features/todo/presentation/widgets/todo_item.dart +++ b/lib/features/todo/presentation/widgets/todo_item.dart @@ -14,18 +14,83 @@ class TodoItem extends StatelessWidget { margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), child: Padding( padding: const EdgeInsets.all(12.0), - child: Column( + child: Row( 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), + Container( + width: 60, + height: 60, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: Colors.grey[300], + ), + clipBehavior: Clip.antiAlias, + child: todo.imageUrl != null && todo.imageUrl!.isNotEmpty + ? Image.network( + todo.imageUrl!, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Icon( + Icons.image_not_supported, + color: Colors.grey[600], + size: 30, + ); + }, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return Center( + child: CircularProgressIndicator( + value: loadingProgress.expectedTotalBytes != null + ? loadingProgress.cumulativeBytesLoaded / + loadingProgress.expectedTotalBytes! + : null, + strokeWidth: 2, + ), + ); + }, + ) + : Icon( + Icons.image, + color: Colors.grey[600], + size: 30, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + todo.title, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + if (todo.description != null && todo.description!.isNotEmpty) ...[ + Text( + todo.description!, + style: const TextStyle(fontSize: 14), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + ], + Text( + todo.formattedDate, + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + fontStyle: FontStyle.italic, + ), + ), + ], + ), ), ], ), ), ); } -} +} \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index 5daddc6..0e2f1f5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -29,10 +29,10 @@ packages: dependency: transitive description: name: async - sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63 + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" url: "https://pub.dev" source: hosted - version: "2.12.0" + version: "2.13.0" bloc: dependency: transitive description: @@ -197,10 +197,10 @@ packages: dependency: transitive description: name: fake_async - sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc" + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" url: "https://pub.dev" source: hosted - version: "1.3.2" + version: "1.3.3" ffi: dependency: transitive description: @@ -388,10 +388,10 @@ packages: dependency: transitive description: name: leak_tracker - sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec + sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" url: "https://pub.dev" source: hosted - version: "10.0.8" + version: "10.0.9" leak_tracker_flutter_testing: dependency: transitive description: @@ -753,10 +753,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14" + sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 url: "https://pub.dev" source: hosted - version: "14.3.1" + version: "15.0.0" watcher: dependency: transitive description: From 487dca6eb2f237b64b4c0f06fd26c44532cadb4b Mon Sep 17 00:00:00 2001 From: rich Date: Tue, 27 May 2025 14:24:10 +0100 Subject: [PATCH 2/4] add toggleTodo to update the isDone state --- .../datasources/todo_remote_datasource.dart | 28 +++++++++ lib/features/todo/data/models/todo_dto.dart | 1 + .../todo/data/models/todo_dto.freezed.dart | 25 +++++++- lib/features/todo/data/models/todo_dto.g.dart | 2 + .../data/repositories/todo_repository.dart | 12 +++- .../todo/domain/models/todo_model.dart | 9 ++- .../domain/models/todo_model.freezed.dart | 35 ++++++++++- .../todo/domain/models/todo_model.g.dart | 2 + .../todo/presentation/cubits/todo_cubit.dart | 26 ++++++++ .../screens/todo_list_screen.dart | 63 ++++++++++++------- .../todo/presentation/widgets/todo_item.dart | 23 ++++++- 11 files changed, 195 insertions(+), 31 deletions(-) diff --git a/lib/features/todo/data/datasources/todo_remote_datasource.dart b/lib/features/todo/data/datasources/todo_remote_datasource.dart index 978645b..88c4be5 100644 --- a/lib/features/todo/data/datasources/todo_remote_datasource.dart +++ b/lib/features/todo/data/datasources/todo_remote_datasource.dart @@ -60,6 +60,34 @@ class TodoRemoteDataSource { } } + Future updateTodo(String id, bool isDone) async { + try { + final updatedTodo = {'id': id, 'isDone': isDone}; + + log('Sending todo: ${json.encode(updatedTodo)}'); + + final response = await _client.patch( + Uri.parse('$baseUrl/todo/$id'), + headers: {'Content-Type': 'application/json'}, + body: json.encode({'id': id, 'isDone': isDone}), + ); + + log('Response status: ${response.statusCode}'); + log('Response body: ${response.body}'); + + if (response.statusCode == 201 || response.statusCode == 200) { + final Map responseData = json.decode(response.body); + + return TodoDTO.fromJson(responseData); + } else { + throw Exception('Failed to create todo: ${response.statusCode}'); + } + } catch (e) { + log('Error adding todo: $e'); + throw Exception('Failed to create 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 7a89933..88d9ff1 100644 --- a/lib/features/todo/data/models/todo_dto.dart +++ b/lib/features/todo/data/models/todo_dto.dart @@ -10,6 +10,7 @@ class TodoDTO with _$TodoDTO { const factory TodoDTO({ required String id, required String title, + required bool isDone, String? description, String? imageUrl, int? createdAtSeconds, diff --git a/lib/features/todo/data/models/todo_dto.freezed.dart b/lib/features/todo/data/models/todo_dto.freezed.dart index d235abf..21e96b2 100644 --- a/lib/features/todo/data/models/todo_dto.freezed.dart +++ b/lib/features/todo/data/models/todo_dto.freezed.dart @@ -23,6 +23,7 @@ TodoDTO _$TodoDTOFromJson(Map json) { mixin _$TodoDTO { String get id => throw _privateConstructorUsedError; String get title => throw _privateConstructorUsedError; + bool get isDone => throw _privateConstructorUsedError; String? get description => throw _privateConstructorUsedError; String? get imageUrl => throw _privateConstructorUsedError; int? get createdAtSeconds => throw _privateConstructorUsedError; @@ -44,6 +45,7 @@ abstract class $TodoDTOCopyWith<$Res> { $Res call({ String id, String title, + bool isDone, String? description, String? imageUrl, int? createdAtSeconds, @@ -67,6 +69,7 @@ class _$TodoDTOCopyWithImpl<$Res, $Val extends TodoDTO> $Res call({ Object? id = null, Object? title = null, + Object? isDone = null, Object? description = freezed, Object? imageUrl = freezed, Object? createdAtSeconds = freezed, @@ -83,6 +86,11 @@ class _$TodoDTOCopyWithImpl<$Res, $Val extends TodoDTO> ? _value.title : title // ignore: cast_nullable_to_non_nullable as String, + isDone: + null == isDone + ? _value.isDone + : isDone // ignore: cast_nullable_to_non_nullable + as bool, description: freezed == description ? _value.description @@ -115,6 +123,7 @@ abstract class _$$TodoDTOImplCopyWith<$Res> implements $TodoDTOCopyWith<$Res> { $Res call({ String id, String title, + bool isDone, String? description, String? imageUrl, int? createdAtSeconds, @@ -137,6 +146,7 @@ class __$$TodoDTOImplCopyWithImpl<$Res> $Res call({ Object? id = null, Object? title = null, + Object? isDone = null, Object? description = freezed, Object? imageUrl = freezed, Object? createdAtSeconds = freezed, @@ -153,6 +163,11 @@ class __$$TodoDTOImplCopyWithImpl<$Res> ? _value.title : title // ignore: cast_nullable_to_non_nullable as String, + isDone: + null == isDone + ? _value.isDone + : isDone // ignore: cast_nullable_to_non_nullable + as bool, description: freezed == description ? _value.description @@ -179,6 +194,7 @@ class _$TodoDTOImpl extends _TodoDTO { const _$TodoDTOImpl({ required this.id, required this.title, + required this.isDone, this.description, this.imageUrl, this.createdAtSeconds, @@ -192,6 +208,8 @@ class _$TodoDTOImpl extends _TodoDTO { @override final String title; @override + final bool isDone; + @override final String? description; @override final String? imageUrl; @@ -200,7 +218,7 @@ class _$TodoDTOImpl extends _TodoDTO { @override String toString() { - return 'TodoDTO(id: $id, title: $title, description: $description, imageUrl: $imageUrl, createdAtSeconds: $createdAtSeconds)'; + return 'TodoDTO(id: $id, title: $title, isDone: $isDone, description: $description, imageUrl: $imageUrl, createdAtSeconds: $createdAtSeconds)'; } @override @@ -210,6 +228,7 @@ class _$TodoDTOImpl extends _TodoDTO { other is _$TodoDTOImpl && (identical(other.id, id) || other.id == id) && (identical(other.title, title) || other.title == title) && + (identical(other.isDone, isDone) || other.isDone == isDone) && (identical(other.description, description) || other.description == description) && (identical(other.imageUrl, imageUrl) || @@ -224,6 +243,7 @@ class _$TodoDTOImpl extends _TodoDTO { runtimeType, id, title, + isDone, description, imageUrl, createdAtSeconds, @@ -247,6 +267,7 @@ abstract class _TodoDTO extends TodoDTO { const factory _TodoDTO({ required final String id, required final String title, + required final bool isDone, final String? description, final String? imageUrl, final int? createdAtSeconds, @@ -260,6 +281,8 @@ abstract class _TodoDTO extends TodoDTO { @override String get title; @override + bool get isDone; + @override String? get description; @override String? get imageUrl; diff --git a/lib/features/todo/data/models/todo_dto.g.dart b/lib/features/todo/data/models/todo_dto.g.dart index b04069b..659ddc6 100644 --- a/lib/features/todo/data/models/todo_dto.g.dart +++ b/lib/features/todo/data/models/todo_dto.g.dart @@ -10,6 +10,7 @@ _$TodoDTOImpl _$$TodoDTOImplFromJson(Map json) => _$TodoDTOImpl( id: json['id'] as String, title: json['title'] as String, + isDone: json['isDone'] as bool, description: json['description'] as String?, imageUrl: json['imageUrl'] as String?, createdAtSeconds: (json['createdAtSeconds'] as num?)?.toInt(), @@ -19,6 +20,7 @@ Map _$$TodoDTOImplToJson(_$TodoDTOImpl instance) => { 'id': instance.id, 'title': instance.title, + 'isDone': instance.isDone, 'description': instance.description, 'imageUrl': instance.imageUrl, 'createdAtSeconds': instance.createdAtSeconds, diff --git a/lib/features/todo/data/repositories/todo_repository.dart b/lib/features/todo/data/repositories/todo_repository.dart index a70cab4..4ada0af 100644 --- a/lib/features/todo/data/repositories/todo_repository.dart +++ b/lib/features/todo/data/repositories/todo_repository.dart @@ -43,6 +43,14 @@ class TodoRepository { } } + Future updateTodo(String id, bool isDone) async { + try { + await remoteDataSource.updateTodo(id, isDone); + } catch (e) { + log('update todo [id:$id] [isDone:$isDone], : $e'); + } + } + // Helper method to map DTOs to domain models TodoModel _mapDtoToModel(TodoDTO dto) { try { @@ -51,10 +59,10 @@ class TodoRepository { createdAt = DateTime.fromMillisecondsSinceEpoch(dto.createdAtSeconds! * 1000); } - return TodoModel(id: dto.id, title: dto.title, description: dto.description, imageUrl: dto.imageUrl, createdAt: createdAt); + return TodoModel(id: dto.id, title: dto.title, isDone: dto.isDone, description: dto.description, imageUrl: dto.imageUrl, 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', 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 656d942..069082f 100644 --- a/lib/features/todo/domain/models/todo_model.dart +++ b/lib/features/todo/domain/models/todo_model.dart @@ -8,7 +8,14 @@ part 'todo_model.g.dart'; class TodoModel with _$TodoModel { const TodoModel._(); - const factory TodoModel({required String id, required String title, String? description, String? imageUrl, DateTime? createdAt}) = + const factory TodoModel({ + required String id, + required String title, + required bool isDone, + String? description, + String? imageUrl, + DateTime? createdAt, + }) = _TodoModel; DateTime get effectiveCreatedAt => createdAt ?? DateTime.now(); diff --git a/lib/features/todo/domain/models/todo_model.freezed.dart b/lib/features/todo/domain/models/todo_model.freezed.dart index 359be31..7582ab1 100644 --- a/lib/features/todo/domain/models/todo_model.freezed.dart +++ b/lib/features/todo/domain/models/todo_model.freezed.dart @@ -23,6 +23,7 @@ TodoModel _$TodoModelFromJson(Map json) { mixin _$TodoModel { String get id => throw _privateConstructorUsedError; String get title => throw _privateConstructorUsedError; + bool get isDone => throw _privateConstructorUsedError; String? get description => throw _privateConstructorUsedError; String? get imageUrl => throw _privateConstructorUsedError; DateTime? get createdAt => throw _privateConstructorUsedError; @@ -45,6 +46,7 @@ abstract class $TodoModelCopyWith<$Res> { $Res call({ String id, String title, + bool isDone, String? description, String? imageUrl, DateTime? createdAt, @@ -68,6 +70,7 @@ class _$TodoModelCopyWithImpl<$Res, $Val extends TodoModel> $Res call({ Object? id = null, Object? title = null, + Object? isDone = null, Object? description = freezed, Object? imageUrl = freezed, Object? createdAt = freezed, @@ -84,6 +87,11 @@ class _$TodoModelCopyWithImpl<$Res, $Val extends TodoModel> ? _value.title : title // ignore: cast_nullable_to_non_nullable as String, + isDone: + null == isDone + ? _value.isDone + : isDone // ignore: cast_nullable_to_non_nullable + as bool, description: freezed == description ? _value.description @@ -117,6 +125,7 @@ abstract class _$$TodoModelImplCopyWith<$Res> $Res call({ String id, String title, + bool isDone, String? description, String? imageUrl, DateTime? createdAt, @@ -139,6 +148,7 @@ class __$$TodoModelImplCopyWithImpl<$Res> $Res call({ Object? id = null, Object? title = null, + Object? isDone = null, Object? description = freezed, Object? imageUrl = freezed, Object? createdAt = freezed, @@ -155,6 +165,11 @@ class __$$TodoModelImplCopyWithImpl<$Res> ? _value.title : title // ignore: cast_nullable_to_non_nullable as String, + isDone: + null == isDone + ? _value.isDone + : isDone // ignore: cast_nullable_to_non_nullable + as bool, description: freezed == description ? _value.description @@ -181,6 +196,7 @@ class _$TodoModelImpl extends _TodoModel { const _$TodoModelImpl({ required this.id, required this.title, + required this.isDone, this.description, this.imageUrl, this.createdAt, @@ -194,6 +210,8 @@ class _$TodoModelImpl extends _TodoModel { @override final String title; @override + final bool isDone; + @override final String? description; @override final String? imageUrl; @@ -202,7 +220,7 @@ class _$TodoModelImpl extends _TodoModel { @override String toString() { - return 'TodoModel(id: $id, title: $title, description: $description, imageUrl: $imageUrl, createdAt: $createdAt)'; + return 'TodoModel(id: $id, title: $title, isDone: $isDone, description: $description, imageUrl: $imageUrl, createdAt: $createdAt)'; } @override @@ -212,6 +230,7 @@ class _$TodoModelImpl extends _TodoModel { other is _$TodoModelImpl && (identical(other.id, id) || other.id == id) && (identical(other.title, title) || other.title == title) && + (identical(other.isDone, isDone) || other.isDone == isDone) && (identical(other.description, description) || other.description == description) && (identical(other.imageUrl, imageUrl) || @@ -222,8 +241,15 @@ class _$TodoModelImpl extends _TodoModel { @JsonKey(includeFromJson: false, includeToJson: false) @override - int get hashCode => - Object.hash(runtimeType, id, title, description, imageUrl, createdAt); + int get hashCode => Object.hash( + runtimeType, + id, + title, + isDone, + description, + imageUrl, + createdAt, + ); /// Create a copy of TodoModel /// with the given fields replaced by the non-null parameter values. @@ -243,6 +269,7 @@ abstract class _TodoModel extends TodoModel { const factory _TodoModel({ required final String id, required final String title, + required final bool isDone, final String? description, final String? imageUrl, final DateTime? createdAt, @@ -257,6 +284,8 @@ abstract class _TodoModel extends TodoModel { @override String get title; @override + bool get isDone; + @override String? get description; @override String? get imageUrl; diff --git a/lib/features/todo/domain/models/todo_model.g.dart b/lib/features/todo/domain/models/todo_model.g.dart index 1af3b76..2a93162 100644 --- a/lib/features/todo/domain/models/todo_model.g.dart +++ b/lib/features/todo/domain/models/todo_model.g.dart @@ -10,6 +10,7 @@ _$TodoModelImpl _$$TodoModelImplFromJson(Map json) => _$TodoModelImpl( id: json['id'] as String, title: json['title'] as String, + isDone: json['isDone'] as bool, description: json['description'] as String?, imageUrl: json['imageUrl'] as String?, createdAt: @@ -22,6 +23,7 @@ Map _$$TodoModelImplToJson(_$TodoModelImpl instance) => { 'id': instance.id, 'title': instance.title, + 'isDone': instance.isDone, 'description': instance.description, 'imageUrl': instance.imageUrl, '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..a28a0dc 100644 --- a/lib/features/todo/presentation/cubits/todo_cubit.dart +++ b/lib/features/todo/presentation/cubits/todo_cubit.dart @@ -30,4 +30,30 @@ class TodoCubit extends Cubit { emit(TodosError(message: e.toString())); } } + + Future toggleTodo(String id, bool isDone) async { + try { + // Optimistic update - update UI immediately + if (state is TodosLoaded) { + final currentTodos = (state as TodosLoaded).todos; + final updatedTodos = currentTodos.map((todo) { + if (todo.id == id) { + return todo.copyWith(isDone: isDone); + } + return todo; + }).toList(); + emit(TodosLoaded(todos: updatedTodos)); + } + + // Make API call + await _repository.updateTodo(id, isDone); + + // Refresh from server to ensure consistency + await loadTodos(); + } catch (e) { + // Revert optimistic update on error + await loadTodos(); + emit(TodosError(message: e.toString())); + } + } } diff --git a/lib/features/todo/presentation/screens/todo_list_screen.dart b/lib/features/todo/presentation/screens/todo_list_screen.dart index a366ba1..c7e4d55 100644 --- a/lib/features/todo/presentation/screens/todo_list_screen.dart +++ b/lib/features/todo/presentation/screens/todo_list_screen.dart @@ -17,29 +17,10 @@ class TodoListScreen extends StatelessWidget { 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; - - if (todos.isEmpty) { - return const Center( - child: Text( - 'No todos yet!\nTap the + button to add one.', - textAlign: TextAlign.center, - style: TextStyle(fontSize: 18), - ), - ); - } - - return ListView.builder( - itemCount: todos.length, - itemBuilder: (context, index) { - return TodoItem(todo: todos[index]); - }, - ); - } else if (state is TodosError) { - return Center(child: Text('Error: ${state.message}', style: const TextStyle(color: Colors.red))); + switch (state) { + case TodosLoading(): return _loadingScreen(); + case TodosLoaded(): return _loadedScreen(state); + case TodosError(): return _errorScreen(state); } return const Center(child: Text('Unknown state')); @@ -58,4 +39,40 @@ class TodoListScreen extends StatelessWidget { ), ); } + + Center _errorScreen(TodosError state) { + return Center(child: Text('Error: ${state.message}', style: const TextStyle(color: Colors.red))); + } + + Widget _loadedScreen(TodosLoaded state) { + final todos = state.todos; + + if (todos.isEmpty) { + return const Center( + child: Text( + 'No todos yet!\nTap the + button to add one.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 18), + ), + ); + } + + return ListView.builder( + itemCount: todos.length, + itemBuilder: (context, index) { + return TodoItem( + todo: todos[index], + onToggle: (bool isDone) { + context.read().toggleTodo(todos[index].id, isDone); + }, + ); + }, + ); + } + + Center _loadingScreen() { + return const Center(child: CircularProgressIndicator()); + } + + } diff --git a/lib/features/todo/presentation/widgets/todo_item.dart b/lib/features/todo/presentation/widgets/todo_item.dart index e11e152..9c0a156 100644 --- a/lib/features/todo/presentation/widgets/todo_item.dart +++ b/lib/features/todo/presentation/widgets/todo_item.dart @@ -4,8 +4,9 @@ import '../../domain/models/todo_model.dart'; class TodoItem extends StatelessWidget { final TodoModel todo; + final ValueChanged? onToggle; - const TodoItem({super.key, required this.todo}); + const TodoItem({super.key, required this.todo, required this.onToggle}); @override Widget build(BuildContext context) { @@ -88,6 +89,26 @@ class TodoItem extends StatelessWidget { ], ), ), + const SizedBox(width: 12), + Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Switch( + value: todo.isDone, + onChanged: onToggle, + activeColor: Colors.green, + activeTrackColor: Colors.green.withValues(alpha: 0.3) + ), + Text( + todo.isDone == true ? 'Done' : 'Todo', + style: TextStyle( + fontSize: 10, + color: Colors.grey[600], + fontWeight: FontWeight.w500, + ), + ), + ], + ), ], ), ), From f954d1f855621c38894e733a832deca8ad8724b6 Mon Sep 17 00:00:00 2001 From: rich Date: Tue, 27 May 2025 15:33:40 +0100 Subject: [PATCH 3/4] add description and imageUrl --- .../datasources/todo_remote_datasource.dart | 15 ++++++++-- .../data/repositories/todo_repository.dart | 4 +-- .../todo/presentation/cubits/todo_cubit.dart | 4 +-- .../screens/todo_form_screen.dart | 30 +++++++++++++++++-- 4 files changed, 45 insertions(+), 8 deletions(-) diff --git a/lib/features/todo/data/datasources/todo_remote_datasource.dart b/lib/features/todo/data/datasources/todo_remote_datasource.dart index 88c4be5..9514543 100644 --- a/lib/features/todo/data/datasources/todo_remote_datasource.dart +++ b/lib/features/todo/data/datasources/todo_remote_datasource.dart @@ -31,9 +31,20 @@ class TodoRemoteDataSource { } } - Future addTodo(String title) async { + Future addTodo(String title, String? description, String? imageUrl) async { try { - final newTodo = {'title': title, 'createdAtSeconds': DateTime.now().millisecondsSinceEpoch ~/ 1000}; + final newTodo = { + 'title': title, + 'createdAtSeconds': DateTime.now().millisecondsSinceEpoch ~/ 1000 + }; + + if (description != null) { + newTodo.addAll({'description': description}); + } + + if (imageUrl != null) { + newTodo.addAll({'imageUrl': imageUrl}); + } log('Sending todo: ${json.encode(newTodo)}'); diff --git a/lib/features/todo/data/repositories/todo_repository.dart b/lib/features/todo/data/repositories/todo_repository.dart index 4ada0af..dd93b1f 100644 --- a/lib/features/todo/data/repositories/todo_repository.dart +++ b/lib/features/todo/data/repositories/todo_repository.dart @@ -34,10 +34,10 @@ class TodoRepository { } // Add a new todo remotely - Future addTodo(String title) async { + Future addTodo(String title, String? description, String? imageUrl) async { try { // Add todo remotely - await remoteDataSource.addTodo(title); + await remoteDataSource.addTodo(title, description, imageUrl); } catch (e) { log('Error adding remote todo: $e'); } diff --git a/lib/features/todo/presentation/cubits/todo_cubit.dart b/lib/features/todo/presentation/cubits/todo_cubit.dart index a28a0dc..c9a41e1 100644 --- a/lib/features/todo/presentation/cubits/todo_cubit.dart +++ b/lib/features/todo/presentation/cubits/todo_cubit.dart @@ -21,10 +21,10 @@ class TodoCubit extends Cubit { } } - Future addTodo(String title) async { + Future addTodo(String title, String? description, String? imageUrl) async { try { emit(TodosLoading()); - await _repository.addTodo(title); + await _repository.addTodo(title, description, imageUrl); await loadTodos(); } catch (e) { emit(TodosError(message: e.toString())); diff --git a/lib/features/todo/presentation/screens/todo_form_screen.dart b/lib/features/todo/presentation/screens/todo_form_screen.dart index cf54e93..38a9b5c 100644 --- a/lib/features/todo/presentation/screens/todo_form_screen.dart +++ b/lib/features/todo/presentation/screens/todo_form_screen.dart @@ -13,16 +13,24 @@ class TodoFormScreen extends StatefulWidget { class _TodoFormScreenState extends State { final _formKey = GlobalKey(); final _titleController = TextEditingController(); + final _descriptionController = TextEditingController(); + final _imageUrlController = TextEditingController(); @override void dispose() { _titleController.dispose(); + _descriptionController.dispose(); + _imageUrlController.dispose(); super.dispose(); } void _submitForm() { if (_formKey.currentState?.validate() ?? false) { - context.read().addTodo(_titleController.text); + context.read().addTodo( + _titleController.text, + _descriptionController.text, + _imageUrlController.text, + ); Navigator.pop(context); } } @@ -36,12 +44,13 @@ class _TodoFormScreenState extends State { child: Form( key: _formKey, child: Column( + spacing: 10, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const SizedBox(height: 16), TextFormField( controller: _titleController, - decoration: const InputDecoration(labelText: 'Title', border: OutlineInputBorder()), + decoration: _buildInputDecoration('Title', true), validator: (value) { if (value == null || value.isEmpty) { return 'Please enter a title'; @@ -49,6 +58,16 @@ class _TodoFormScreenState extends State { return null; }, ), + TextFormField( + controller: _descriptionController, + decoration: _buildInputDecoration('Description', false), + minLines: 4, + maxLines: 8, + ), + TextFormField( + controller: _imageUrlController, + decoration: _buildInputDecoration('Image URL', false), + ), const SizedBox(height: 24), ElevatedButton( onPressed: _submitForm, @@ -61,4 +80,11 @@ class _TodoFormScreenState extends State { ), ); } + + InputDecoration _buildInputDecoration(String labelText, bool? required) => InputDecoration( + suffixText: required! ? '*' : null, + labelStyle: required ? TextStyle(color: Colors.red) : null, + labelText: labelText, + border: OutlineInputBorder() + ); } From ab58265b6c4e25ad84b4e88cf3381f714fa27c05 Mon Sep 17 00:00:00 2001 From: rich Date: Tue, 27 May 2025 17:01:38 +0100 Subject: [PATCH 4/4] favourites using context menu --- .../data/repositories/todo_repository.dart | 34 +++ .../todo/presentation/cubits/todo_cubit.dart | 36 ++- .../todo/presentation/cubits/todo_state.dart | 8 +- .../todo/presentation/widgets/todo_item.dart | 263 ++++++++++++------ 4 files changed, 248 insertions(+), 93 deletions(-) diff --git a/lib/features/todo/data/repositories/todo_repository.dart b/lib/features/todo/data/repositories/todo_repository.dart index dd93b1f..52be3dc 100644 --- a/lib/features/todo/data/repositories/todo_repository.dart +++ b/lib/features/todo/data/repositories/todo_repository.dart @@ -1,6 +1,7 @@ import 'dart:developer'; import 'package:injectable/injectable.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:uuid/uuid.dart'; import '../../domain/models/todo_model.dart'; @@ -10,6 +11,8 @@ import '../models/todo_dto.dart'; @injectable class TodoRepository { final TodoRemoteDataSource remoteDataSource; + static const String _favouritePrefix = 'favourite_'; + static const String _favouritesList = 'favourites_list'; TodoRepository(this.remoteDataSource); @@ -51,6 +54,37 @@ class TodoRepository { } } + Future> getFavouriteIds() async { + final prefs = await SharedPreferences.getInstance(); + final favouritesList = prefs.getStringList(_favouritesList) ?? []; + return Set.from(favouritesList); + } + + Future addFavourite(String todoId) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('$_favouritePrefix$todoId', true); + + final favouritesList = prefs.getStringList(_favouritesList) ?? []; + if (!favouritesList.contains(todoId)) { + favouritesList.add(todoId); + await prefs.setStringList(_favouritesList, favouritesList); + } + } + + Future removeFavourite(String todoId) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove('$_favouritePrefix$todoId'); + + final favouritesList = prefs.getStringList(_favouritesList) ?? []; + favouritesList.remove(todoId); + await prefs.setStringList(_favouritesList, favouritesList); + } + + Future isFavourite(String todoId) async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool('$_favouritePrefix$todoId') ?? false; + } + // Helper method to map DTOs to domain models TodoModel _mapDtoToModel(TodoDTO dto) { try { diff --git a/lib/features/todo/presentation/cubits/todo_cubit.dart b/lib/features/todo/presentation/cubits/todo_cubit.dart index c9a41e1..ed1dfd5 100644 --- a/lib/features/todo/presentation/cubits/todo_cubit.dart +++ b/lib/features/todo/presentation/cubits/todo_cubit.dart @@ -15,7 +15,9 @@ class TodoCubit extends Cubit { try { emit(TodosLoading()); final todos = await _repository.getTodos(); - emit(TodosLoaded(todos: todos)); + final favouriteIds = await _repository.getFavouriteIds(); + + emit(TodosLoaded(todos: todos, favouriteIds: favouriteIds)); } catch (e) { emit(TodosError(message: e.toString())); } @@ -36,13 +38,14 @@ class TodoCubit extends Cubit { // Optimistic update - update UI immediately if (state is TodosLoaded) { final currentTodos = (state as TodosLoaded).todos; + final favouriteTodos = (state as TodosLoaded).favouriteIds; final updatedTodos = currentTodos.map((todo) { if (todo.id == id) { return todo.copyWith(isDone: isDone); } return todo; }).toList(); - emit(TodosLoaded(todos: updatedTodos)); + emit(TodosLoaded(todos: updatedTodos, favouriteIds: favouriteTodos)); } // Make API call @@ -56,4 +59,33 @@ class TodoCubit extends Cubit { emit(TodosError(message: e.toString())); } } + + Future toggleFavourite(String todoId) async { + final currentState = state; + if (currentState is! TodosLoaded) return; + + try { + final newFavouriteIds = Set.from(currentState.favouriteIds); + if (newFavouriteIds.contains(todoId)) { + newFavouriteIds.remove(todoId); + await _repository.removeFavourite(todoId); + } else { + newFavouriteIds.add(todoId); + await _repository.addFavourite(todoId); + } + + emit(TodosLoaded(todos: currentState.todos, favouriteIds: newFavouriteIds)); + } catch (e) { + emit(TodosError(message: 'Failed to update favourite: $e')); + loadTodos(); + } + } + + bool isFavourite(String todoId) { + final currentState = state; + if (currentState is TodosLoaded) { + return currentState.favouriteIds.contains(todoId); + } + return false; + } } diff --git a/lib/features/todo/presentation/cubits/todo_state.dart b/lib/features/todo/presentation/cubits/todo_state.dart index e39b174..fa3c552 100644 --- a/lib/features/todo/presentation/cubits/todo_state.dart +++ b/lib/features/todo/presentation/cubits/todo_state.dart @@ -11,11 +11,15 @@ class TodosLoading extends TodoState {} class TodosLoaded extends TodoState { final List todos; + final Set favouriteIds; - const TodosLoaded({required this.todos}); + const TodosLoaded({ + required this.todos, + required this.favouriteIds, + }); @override - List get props => [todos]; + List get props => [todos, favouriteIds]; } class TodosError extends TodoState { diff --git a/lib/features/todo/presentation/widgets/todo_item.dart b/lib/features/todo/presentation/widgets/todo_item.dart index 9c0a156..766417e 100644 --- a/lib/features/todo/presentation/widgets/todo_item.dart +++ b/lib/features/todo/presentation/widgets/todo_item.dart @@ -1,117 +1,202 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import '../../domain/models/todo_model.dart'; +import '../cubits/todo_cubit.dart'; class TodoItem extends StatelessWidget { final TodoModel todo; final ValueChanged? onToggle; - const TodoItem({super.key, required this.todo, required this.onToggle}); + const TodoItem({ + super.key, + required this.todo, + this.onToggle, + }); - @override - Widget build(BuildContext context) { - return Card( - elevation: 2, - margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - child: Padding( - padding: const EdgeInsets.all(12.0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, + void _showContextMenu(BuildContext context) { + final cubit = context.read(); + final isFavourite = cubit.isFavourite(todo.id); + + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => Container( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, children: [ Container( - width: 60, - height: 60, + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: 16), decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), color: Colors.grey[300], + borderRadius: BorderRadius.circular(2), ), - clipBehavior: Clip.antiAlias, - child: todo.imageUrl != null && todo.imageUrl!.isNotEmpty - ? Image.network( - todo.imageUrl!, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return Icon( - Icons.image_not_supported, - color: Colors.grey[600], - size: 30, - ); - }, - loadingBuilder: (context, child, loadingProgress) { - if (loadingProgress == null) return child; - return Center( - child: CircularProgressIndicator( - value: loadingProgress.expectedTotalBytes != null - ? loadingProgress.cumulativeBytesLoaded / - loadingProgress.expectedTotalBytes! - : null, - strokeWidth: 2, - ), - ); - }, - ) - : Icon( - Icons.image, - color: Colors.grey[600], - size: 30, + ), + ListTile( + leading: Icon( + isFavourite ? Icons.favorite : Icons.favorite_border, + color: isFavourite ? Colors.red : null, ), + title: Text(isFavourite ? 'Remove from Favourites' : 'Add to Favourites'), + onTap: () { + Navigator.pop(context); + cubit.toggleFavourite(todo.id); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + isFavourite + ? 'Removed from favourites' + : 'Added to favourites' + ), + duration: const Duration(seconds: 2), + behavior: SnackBarBehavior.floating, + action: SnackBarAction( + label: 'Undo', + onPressed: () => cubit.toggleFavourite(todo.id), + ), + ), + ); + }, ), - const SizedBox(width: 12), - Expanded( - child: Column( + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final isFavourite = state is TodosLoaded && + state.favouriteIds.contains(todo.id); + + return GestureDetector( + onLongPress: () => _showContextMenu(context), + child: Card( + elevation: 2, + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - todo.title, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, + Container( + width: 60, + height: 60, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: Colors.grey[300], ), - ), - const SizedBox(height: 4), - if (todo.description != null && todo.description!.isNotEmpty) ...[ - Text( - todo.description!, - style: const TextStyle(fontSize: 14), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 4), - ], - Text( - todo.formattedDate, - style: TextStyle( - fontSize: 12, + clipBehavior: Clip.antiAlias, + child: todo.imageUrl != null && todo.imageUrl!.isNotEmpty + ? Image.network( + todo.imageUrl!, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Icon( + Icons.image_not_supported, + color: Colors.grey[600], + size: 30, + ); + }, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return Center( + child: CircularProgressIndicator( + value: loadingProgress.expectedTotalBytes != null + ? loadingProgress.cumulativeBytesLoaded / + loadingProgress.expectedTotalBytes! + : null, + strokeWidth: 2, + ), + ); + }, + ) + : Icon( + Icons.image, color: Colors.grey[600], - fontStyle: FontStyle.italic, + size: 30, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (isFavourite) + const Icon( + Icons.favorite, + color: Colors.red, + size: 16, + ), + const SizedBox(width: 4), + Expanded( + child: Text( + todo.title, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + const SizedBox(height: 4), + if (todo.description != null && todo.description!.isNotEmpty) ...[ + Text( + todo.description!, + style: const TextStyle(fontSize: 14), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + ], + Text( + todo.formattedDate, + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + fontStyle: FontStyle.italic, + ), + ), + ], ), ), + const SizedBox(width: 12), + Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Switch( + value: todo.isDone, + onChanged: onToggle, + activeColor: Colors.green, + activeTrackColor: Colors.green.withValues(alpha: 0.3) + ), + Text( + todo.isDone == true ? 'Done' : 'Todo', + style: TextStyle( + fontSize: 10, + color: Colors.grey[600], + fontWeight: FontWeight.w500, + ), + ), + ], + ), ], ), ), - const SizedBox(width: 12), - Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Switch( - value: todo.isDone, - onChanged: onToggle, - activeColor: Colors.green, - activeTrackColor: Colors.green.withValues(alpha: 0.3) - ), - Text( - todo.isDone == true ? 'Done' : 'Todo', - style: TextStyle( - fontSize: 10, - color: Colors.grey[600], - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ], - ), - ), + ), + ); + }, ); } } \ No newline at end of file