From 38a33743e602860a1f96363dd0bfc7e05a59b2d6 Mon Sep 17 00:00:00 2001 From: Phuoc Nguyen Date: Fri, 3 Oct 2025 17:54:39 +0700 Subject: [PATCH] fix todo --- ios/Podfile.lock | 8 +- lib/core/constants/environment_config.dart | 178 +++++----- lib/core/network/api_constants.dart | 24 +- lib/core/providers/app_providers.dart | 6 +- lib/core/providers/app_providers.g.dart | 2 +- lib/core/routing/app_router.dart | 2 +- .../datasources/todo_remote_datasource.dart | 28 ++ .../todos/data/models/todo_model.dart | 46 +++ .../todos/data/models/todo_model.freezed.dart | 325 ++++++++++++++++++ .../todos/data/models/todo_model.g.dart | 33 ++ .../repositories/todo_repository_impl.dart | 49 +++ lib/features/todos/domain/entities/todo.dart | 44 +++ .../domain/repositories/todo_repository.dart | 8 + .../providers/todo_providers.dart | 112 ++++++ .../providers/todo_providers.g.dart | 259 ++++++++++++++ .../presentation/screens/home_screen.dart | 200 +++++------ 16 files changed, 1096 insertions(+), 228 deletions(-) create mode 100644 lib/features/todos/data/datasources/todo_remote_datasource.dart create mode 100644 lib/features/todos/data/models/todo_model.dart create mode 100644 lib/features/todos/data/models/todo_model.freezed.dart create mode 100644 lib/features/todos/data/models/todo_model.g.dart create mode 100644 lib/features/todos/data/repositories/todo_repository_impl.dart create mode 100644 lib/features/todos/domain/entities/todo.dart create mode 100644 lib/features/todos/domain/repositories/todo_repository.dart create mode 100644 lib/features/todos/presentation/providers/todo_providers.dart create mode 100644 lib/features/todos/presentation/providers/todo_providers.g.dart diff --git a/ios/Podfile.lock b/ios/Podfile.lock index c2dd560..9676cd1 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -31,11 +31,11 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/sqflite_darwin/darwin" SPEC CHECKSUMS: - connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd + connectivity_plus: 2a701ffec2c0ae28a48cf7540e279787e77c447d Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 - flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 - path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 - sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 + flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12 + path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 + sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e diff --git a/lib/core/constants/environment_config.dart b/lib/core/constants/environment_config.dart index 5cfabdc..60efa70 100644 --- a/lib/core/constants/environment_config.dart +++ b/lib/core/constants/environment_config.dart @@ -1,7 +1,6 @@ /// Environment configuration for API endpoints and settings enum Environment { development, - staging, production, } @@ -13,107 +12,75 @@ class EnvironmentConfig { /// Current environment - Change this to switch environments static const Environment currentEnvironment = Environment.development; + /// Environment configurations as JSON map for easy editing + static const Map> _configs = { + Environment.development: { + 'baseUrl': 'http://103.188.82.191:4003', + 'apiPath': '', + 'connectTimeout': 30000, + 'receiveTimeout': 30000, + 'sendTimeout': 30000, + 'enableLogging': true, + 'enableDetailedLogging': true, + 'enableCertificatePinning': false, + 'maxRetries': 3, + 'retryDelay': 1000, // milliseconds + }, + Environment.production: { + 'baseUrl': 'https://api.example.com', + 'apiPath': '/api/v1', + 'connectTimeout': 30000, + 'receiveTimeout': 30000, + 'sendTimeout': 30000, + 'enableLogging': false, + 'enableDetailedLogging': false, + 'enableCertificatePinning': true, + 'maxRetries': 3, + 'retryDelay': 1000, // milliseconds + }, + }; + + /// Get current environment configuration + static Map get _currentConfig => _configs[currentEnvironment]!; + /// Get base URL for current environment - static String get baseUrl { - switch (currentEnvironment) { - case Environment.development: - return 'http://103.188.82.191:4003'; - case Environment.staging: - return 'https://api-staging.example.com'; - case Environment.production: - return 'https://api.example.com'; - } - } + static String get baseUrl => _currentConfig['baseUrl'] as String; /// Get API path for current environment - static String get apiPath { - switch (currentEnvironment) { - case Environment.development: - // No API prefix for local development - endpoints are directly at /auth/ - return ''; - case Environment.staging: - case Environment.production: - return '/api/v1'; - } - } + static String get apiPath => _currentConfig['apiPath'] as String; /// Check if current environment is development static bool get isDevelopment => currentEnvironment == Environment.development; - /// Check if current environment is staging - static bool get isStaging => currentEnvironment == Environment.staging; - /// Check if current environment is production static bool get isProduction => currentEnvironment == Environment.production; - /// Get timeout configurations based on environment - static int get connectTimeout { - switch (currentEnvironment) { - case Environment.development: - return 10000; // 10 seconds for local development - case Environment.staging: - return 20000; // 20 seconds for staging - case Environment.production: - return 30000; // 30 seconds for production - } - } + /// Check if current environment is staging (for backward compatibility, always false) + static bool get isStaging => false; - static int get receiveTimeout { - switch (currentEnvironment) { - case Environment.development: - return 15000; // 15 seconds for local development - case Environment.staging: - return 25000; // 25 seconds for staging - case Environment.production: - return 30000; // 30 seconds for production - } - } + /// Timeout configurations from config map + static int get connectTimeout => _currentConfig['connectTimeout'] as int; + static int get receiveTimeout => _currentConfig['receiveTimeout'] as int; + static int get sendTimeout => _currentConfig['sendTimeout'] as int; - static int get sendTimeout { - switch (currentEnvironment) { - case Environment.development: - return 15000; // 15 seconds for local development - case Environment.staging: - return 25000; // 25 seconds for staging - case Environment.production: - return 30000; // 30 seconds for production - } - } + /// Enable/disable features from config map + static bool get enableLogging => _currentConfig['enableLogging'] as bool; + static bool get enableDetailedLogging => _currentConfig['enableDetailedLogging'] as bool; + static bool get enableCertificatePinning => _currentConfig['enableCertificatePinning'] as bool; - /// Get retry configurations based on environment - static int get maxRetries { - switch (currentEnvironment) { - case Environment.development: - return 2; // Fewer retries for local development - case Environment.staging: - return 3; // Standard retries for staging - case Environment.production: - return 3; // Standard retries for production - } - } + /// Retry configurations + static int get maxRetries => _currentConfig['maxRetries'] as int; + static Duration get retryDelay => Duration(milliseconds: _currentConfig['retryDelay'] as int); - static Duration get retryDelay { - switch (currentEnvironment) { - case Environment.development: - return const Duration(milliseconds: 500); // Faster retry for local - case Environment.staging: - return const Duration(seconds: 1); // Standard retry delay - case Environment.production: - return const Duration(seconds: 1); // Standard retry delay - } - } - - /// Enable/disable features based on environment - static bool get enableLogging => !isProduction; - static bool get enableDetailedLogging => isDevelopment; - static bool get enableCertificatePinning => isProduction; - - /// Authentication endpoints (consistent across environments) + /// Authentication endpoints static const String authEndpoint = '/auth'; static const String loginEndpoint = '$authEndpoint/login'; static const String registerEndpoint = '$authEndpoint/register'; static const String refreshEndpoint = '$authEndpoint/refresh'; static const String logoutEndpoint = '$authEndpoint/logout'; + static const String resetPasswordEndpoint = '$authEndpoint/reset-password'; + static const String changePasswordEndpoint = '$authEndpoint/change-password'; + static const String verifyEmailEndpoint = '$authEndpoint/verify-email'; /// Full API URLs static String get fullBaseUrl => baseUrl + apiPath; @@ -121,20 +88,43 @@ class EnvironmentConfig { static String get registerUrl => baseUrl + registerEndpoint; static String get refreshUrl => baseUrl + refreshEndpoint; static String get logoutUrl => baseUrl + logoutEndpoint; + static String get resetPasswordUrl => baseUrl + resetPasswordEndpoint; + static String get changePasswordUrl => baseUrl + changePasswordEndpoint; + static String get verifyEmailUrl => baseUrl + verifyEmailEndpoint; + + /// User endpoints + static const String userEndpoint = '/user'; + static const String profileEndpoint = '$userEndpoint/profile'; + static const String updateProfileEndpoint = '$userEndpoint/update'; + static const String deleteAccountEndpoint = '$userEndpoint/delete'; + + /// Full User URLs + static String get profileUrl => baseUrl + profileEndpoint; + static String get updateProfileUrl => baseUrl + updateProfileEndpoint; + static String get deleteAccountUrl => baseUrl + deleteAccountEndpoint; + + /// Todo endpoints + static const String todosEndpoint = '/todo'; + + /// Full Todo URLs + static String get todosUrl => baseUrl + todosEndpoint; /// Debug information static Map get debugInfo => { 'environment': currentEnvironment.name, - 'baseUrl': baseUrl, - 'apiPath': apiPath, - 'fullBaseUrl': fullBaseUrl, - 'connectTimeout': connectTimeout, - 'receiveTimeout': receiveTimeout, - 'sendTimeout': sendTimeout, - 'maxRetries': maxRetries, - 'retryDelay': retryDelay.inMilliseconds, - 'enableLogging': enableLogging, - 'enableDetailedLogging': enableDetailedLogging, - 'enableCertificatePinning': enableCertificatePinning, + 'config': _currentConfig, + 'endpoints': { + 'login': loginUrl, + 'register': registerUrl, + 'refresh': refreshUrl, + 'logout': logoutUrl, + 'profile': profileUrl, + }, }; + + /// Get a specific config value + static T? getConfig(String key) => _currentConfig[key] as T?; + + /// Check if a config key exists + static bool hasConfig(String key) => _currentConfig.containsKey(key); } \ No newline at end of file diff --git a/lib/core/network/api_constants.dart b/lib/core/network/api_constants.dart index f1664f6..08e4b21 100644 --- a/lib/core/network/api_constants.dart +++ b/lib/core/network/api_constants.dart @@ -9,12 +9,12 @@ class ApiConstants { static String get baseUrl => EnvironmentConfig.baseUrl; static String get apiPath => EnvironmentConfig.apiPath; - // Timeout configurations (environment-specific) + // Timeout configurations static int get connectTimeout => EnvironmentConfig.connectTimeout; static int get receiveTimeout => EnvironmentConfig.receiveTimeout; static int get sendTimeout => EnvironmentConfig.sendTimeout; - // Retry configurations (environment-specific) + // Retry configurations static int get maxRetries => EnvironmentConfig.maxRetries; static Duration get retryDelay => EnvironmentConfig.retryDelay; @@ -28,19 +28,21 @@ class ApiConstants { static const String bearerPrefix = 'Bearer'; static const String apiKeyHeaderKey = 'X-API-Key'; - // Authentication endpoints (from environment config) + // Authentication endpoints static String get authEndpoint => EnvironmentConfig.authEndpoint; static String get loginEndpoint => EnvironmentConfig.loginEndpoint; static String get registerEndpoint => EnvironmentConfig.registerEndpoint; static String get refreshEndpoint => EnvironmentConfig.refreshEndpoint; static String get logoutEndpoint => EnvironmentConfig.logoutEndpoint; - static const String userEndpoint = '/user'; - static const String profileEndpoint = '$userEndpoint/profile'; + static String get resetPasswordEndpoint => EnvironmentConfig.resetPasswordEndpoint; + static String get changePasswordEndpoint => EnvironmentConfig.changePasswordEndpoint; + static String get verifyEmailEndpoint => EnvironmentConfig.verifyEmailEndpoint; - // Example service endpoints (for demonstration) - static const String todosEndpoint = '/todos'; - static const String postsEndpoint = '/posts'; - static const String usersEndpoint = '/users'; + // User endpoints + static String get userEndpoint => EnvironmentConfig.userEndpoint; + static String get profileEndpoint => EnvironmentConfig.profileEndpoint; + static String get updateProfileEndpoint => EnvironmentConfig.updateProfileEndpoint; + static String get deleteAccountEndpoint => EnvironmentConfig.deleteAccountEndpoint; // Cache configurations static const Duration cacheMaxAge = Duration(minutes: 5); @@ -66,8 +68,4 @@ class ApiConstants { static bool get enableLogging => EnvironmentConfig.enableLogging; static bool get enableCertificatePinning => EnvironmentConfig.enableCertificatePinning; static bool get enableDetailedLogging => EnvironmentConfig.enableDetailedLogging; - - // API rate limiting - static const int maxRequestsPerMinute = 100; - static const Duration rateLimitWindow = Duration(minutes: 1); } \ No newline at end of file diff --git a/lib/core/providers/app_providers.dart b/lib/core/providers/app_providers.dart index 5e153ef..4889e8b 100644 --- a/lib/core/providers/app_providers.dart +++ b/lib/core/providers/app_providers.dart @@ -394,9 +394,9 @@ class ApiConnectivityTest extends _$ApiConnectivityTest { 'message': 'Configuration loaded successfully', 'endpoints': { 'login': EnvironmentConfig.loginUrl, - 'register': EnvironmentConfig.registerUrl, - 'refresh': EnvironmentConfig.refreshUrl, - 'logout': EnvironmentConfig.logoutUrl, + // 'register': EnvironmentConfig.registerUrl, + // 'refresh': EnvironmentConfig.refreshUrl, + // 'logout': EnvironmentConfig.logoutUrl, }, 'settings': { 'connectTimeout': EnvironmentConfig.connectTimeout, diff --git a/lib/core/providers/app_providers.g.dart b/lib/core/providers/app_providers.g.dart index 7a76ff1..0816b54 100644 --- a/lib/core/providers/app_providers.g.dart +++ b/lib/core/providers/app_providers.g.dart @@ -216,7 +216,7 @@ final errorTrackerProvider = AutoDisposeNotifierProvider>>; String _$apiConnectivityTestHash() => - r'19c63d75d09ad8f95452afb1a409528fcdd5cbaa'; + r'af903de0fec684ef6c701190dfca2a25f97a9392'; /// API connectivity test provider /// diff --git a/lib/core/routing/app_router.dart b/lib/core/routing/app_router.dart index 8ffe5cc..d7b039d 100644 --- a/lib/core/routing/app_router.dart +++ b/lib/core/routing/app_router.dart @@ -128,7 +128,7 @@ final routerProvider = Provider((ref) { path: RoutePaths.todos, name: RouteNames.todos, pageBuilder: (context, state) => _buildPageWithTransition( - child: const HomeScreen(), // Using existing TodoScreen + child: const TodoScreen(), // Using existing TodoScreen state: state, ), routes: [ diff --git a/lib/features/todos/data/datasources/todo_remote_datasource.dart b/lib/features/todos/data/datasources/todo_remote_datasource.dart new file mode 100644 index 0000000..e66d1cf --- /dev/null +++ b/lib/features/todos/data/datasources/todo_remote_datasource.dart @@ -0,0 +1,28 @@ +import 'package:base_flutter/core/utils/utils.dart'; +import '../../../../core/network/dio_client.dart'; +import '../../../../core/constants/environment_config.dart'; +import '../../../../core/services/api_service.dart'; +import '../models/todo_model.dart'; + +abstract class TodoRemoteDataSource { + Future> getTodos(); +} + +class TodoRemoteDataSourceImpl extends BaseApiService + implements TodoRemoteDataSource { + TodoRemoteDataSourceImpl({required DioClient dioClient}) : super(dioClient); + + @override + Future> getTodos() async { + final response = await dioClient.get(EnvironmentConfig.todosEndpoint); + + if (response.data is List) { + final List todosJson = response.data as List; + return todosJson + .map((json) => TodoModel.fromJson(json as DataMap)) + .toList(); + } else { + throw Exception('Expected List but got ${response.data.runtimeType}'); + } + } +} diff --git a/lib/features/todos/data/models/todo_model.dart b/lib/features/todos/data/models/todo_model.dart new file mode 100644 index 0000000..b6b049b --- /dev/null +++ b/lib/features/todos/data/models/todo_model.dart @@ -0,0 +1,46 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import '../../domain/entities/todo.dart'; + +part 'todo_model.freezed.dart'; +part 'todo_model.g.dart'; + +@freezed +class TodoModel with _$TodoModel { + const factory TodoModel({ + required int id, + required String title, + String? description, + required bool completed, + required String userId, + @JsonKey(includeFromJson: false, includeToJson: false) Map? user, + DateTime? createdAt, + DateTime? updatedAt, + }) = _TodoModel; + + const TodoModel._(); + + factory TodoModel.fromJson(Map json) => + _$TodoModelFromJson(json); + + /// Convert to domain entity + Todo toEntity() => Todo( + id: id, + title: title, + description: description, + completed: completed, + userId: userId, + createdAt: createdAt, + updatedAt: updatedAt, + ); + + /// Create from domain entity + factory TodoModel.fromEntity(Todo todo) => TodoModel( + id: todo.id, + title: todo.title, + description: todo.description, + completed: todo.completed, + userId: todo.userId, + createdAt: todo.createdAt, + updatedAt: todo.updatedAt, + ); +} diff --git a/lib/features/todos/data/models/todo_model.freezed.dart b/lib/features/todos/data/models/todo_model.freezed.dart new file mode 100644 index 0000000..009a404 --- /dev/null +++ b/lib/features/todos/data/models/todo_model.freezed.dart @@ -0,0 +1,325 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'todo_model.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); + +TodoModel _$TodoModelFromJson(Map json) { + return _TodoModel.fromJson(json); +} + +/// @nodoc +mixin _$TodoModel { + int get id => throw _privateConstructorUsedError; + String get title => throw _privateConstructorUsedError; + String? get description => throw _privateConstructorUsedError; + bool get completed => throw _privateConstructorUsedError; + String get userId => throw _privateConstructorUsedError; + @JsonKey(includeFromJson: false, includeToJson: false) + Map? get user => throw _privateConstructorUsedError; + DateTime? get createdAt => throw _privateConstructorUsedError; + DateTime? get updatedAt => throw _privateConstructorUsedError; + + Map toJson() => throw _privateConstructorUsedError; + @JsonKey(ignore: true) + $TodoModelCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $TodoModelCopyWith<$Res> { + factory $TodoModelCopyWith(TodoModel value, $Res Function(TodoModel) then) = + _$TodoModelCopyWithImpl<$Res, TodoModel>; + @useResult + $Res call( + {int id, + String title, + String? description, + bool completed, + String userId, + @JsonKey(includeFromJson: false, includeToJson: false) + Map? user, + DateTime? createdAt, + DateTime? updatedAt}); +} + +/// @nodoc +class _$TodoModelCopyWithImpl<$Res, $Val extends TodoModel> + implements $TodoModelCopyWith<$Res> { + _$TodoModelCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? title = null, + Object? description = freezed, + Object? completed = null, + Object? userId = null, + Object? user = freezed, + Object? createdAt = freezed, + Object? updatedAt = freezed, + }) { + return _then(_value.copyWith( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as int, + title: null == title + ? _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?, + completed: null == completed + ? _value.completed + : completed // ignore: cast_nullable_to_non_nullable + as bool, + userId: null == userId + ? _value.userId + : userId // ignore: cast_nullable_to_non_nullable + as String, + user: freezed == user + ? _value.user + : user // ignore: cast_nullable_to_non_nullable + as Map?, + createdAt: freezed == createdAt + ? _value.createdAt + : createdAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + updatedAt: freezed == updatedAt + ? _value.updatedAt + : updatedAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$TodoModelImplCopyWith<$Res> + implements $TodoModelCopyWith<$Res> { + factory _$$TodoModelImplCopyWith( + _$TodoModelImpl value, $Res Function(_$TodoModelImpl) then) = + __$$TodoModelImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {int id, + String title, + String? description, + bool completed, + String userId, + @JsonKey(includeFromJson: false, includeToJson: false) + Map? user, + DateTime? createdAt, + DateTime? updatedAt}); +} + +/// @nodoc +class __$$TodoModelImplCopyWithImpl<$Res> + extends _$TodoModelCopyWithImpl<$Res, _$TodoModelImpl> + implements _$$TodoModelImplCopyWith<$Res> { + __$$TodoModelImplCopyWithImpl( + _$TodoModelImpl _value, $Res Function(_$TodoModelImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? title = null, + Object? description = freezed, + Object? completed = null, + Object? userId = null, + Object? user = freezed, + Object? createdAt = freezed, + Object? updatedAt = freezed, + }) { + return _then(_$TodoModelImpl( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as int, + title: null == title + ? _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?, + completed: null == completed + ? _value.completed + : completed // ignore: cast_nullable_to_non_nullable + as bool, + userId: null == userId + ? _value.userId + : userId // ignore: cast_nullable_to_non_nullable + as String, + user: freezed == user + ? _value._user + : user // ignore: cast_nullable_to_non_nullable + as Map?, + createdAt: freezed == createdAt + ? _value.createdAt + : createdAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + updatedAt: freezed == updatedAt + ? _value.updatedAt + : updatedAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$TodoModelImpl extends _TodoModel { + const _$TodoModelImpl( + {required this.id, + required this.title, + this.description, + required this.completed, + required this.userId, + @JsonKey(includeFromJson: false, includeToJson: false) + final Map? user, + this.createdAt, + this.updatedAt}) + : _user = user, + super._(); + + factory _$TodoModelImpl.fromJson(Map json) => + _$$TodoModelImplFromJson(json); + + @override + final int id; + @override + final String title; + @override + final String? description; + @override + final bool completed; + @override + final String userId; + final Map? _user; + @override + @JsonKey(includeFromJson: false, includeToJson: false) + Map? get user { + final value = _user; + if (value == null) return null; + if (_user is EqualUnmodifiableMapView) return _user; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(value); + } + + @override + final DateTime? createdAt; + @override + final DateTime? updatedAt; + + @override + String toString() { + return 'TodoModel(id: $id, title: $title, description: $description, completed: $completed, userId: $userId, user: $user, createdAt: $createdAt, updatedAt: $updatedAt)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + 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.completed, completed) || + other.completed == completed) && + (identical(other.userId, userId) || other.userId == userId) && + const DeepCollectionEquality().equals(other._user, _user) && + (identical(other.createdAt, createdAt) || + other.createdAt == createdAt) && + (identical(other.updatedAt, updatedAt) || + other.updatedAt == updatedAt)); + } + + @JsonKey(ignore: true) + @override + int get hashCode => Object.hash( + runtimeType, + id, + title, + description, + completed, + userId, + const DeepCollectionEquality().hash(_user), + createdAt, + updatedAt); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$TodoModelImplCopyWith<_$TodoModelImpl> get copyWith => + __$$TodoModelImplCopyWithImpl<_$TodoModelImpl>(this, _$identity); + + @override + Map toJson() { + return _$$TodoModelImplToJson( + this, + ); + } +} + +abstract class _TodoModel extends TodoModel { + const factory _TodoModel( + {required final int id, + required final String title, + final String? description, + required final bool completed, + required final String userId, + @JsonKey(includeFromJson: false, includeToJson: false) + final Map? user, + final DateTime? createdAt, + final DateTime? updatedAt}) = _$TodoModelImpl; + const _TodoModel._() : super._(); + + factory _TodoModel.fromJson(Map json) = + _$TodoModelImpl.fromJson; + + @override + int get id; + @override + String get title; + @override + String? get description; + @override + bool get completed; + @override + String get userId; + @override + @JsonKey(includeFromJson: false, includeToJson: false) + Map? get user; + @override + DateTime? get createdAt; + @override + DateTime? get updatedAt; + @override + @JsonKey(ignore: true) + _$$TodoModelImplCopyWith<_$TodoModelImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/features/todos/data/models/todo_model.g.dart b/lib/features/todos/data/models/todo_model.g.dart new file mode 100644 index 0000000..c39074c --- /dev/null +++ b/lib/features/todos/data/models/todo_model.g.dart @@ -0,0 +1,33 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'todo_model.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$TodoModelImpl _$$TodoModelImplFromJson(Map json) => + _$TodoModelImpl( + id: (json['id'] as num).toInt(), + title: json['title'] as String, + description: json['description'] as String?, + completed: json['completed'] as bool, + userId: json['userId'] as String, + createdAt: json['createdAt'] == null + ? null + : DateTime.parse(json['createdAt'] as String), + updatedAt: json['updatedAt'] == null + ? null + : DateTime.parse(json['updatedAt'] as String), + ); + +Map _$$TodoModelImplToJson(_$TodoModelImpl instance) => + { + 'id': instance.id, + 'title': instance.title, + 'description': instance.description, + 'completed': instance.completed, + 'userId': instance.userId, + 'createdAt': instance.createdAt?.toIso8601String(), + 'updatedAt': instance.updatedAt?.toIso8601String(), + }; diff --git a/lib/features/todos/data/repositories/todo_repository_impl.dart b/lib/features/todos/data/repositories/todo_repository_impl.dart new file mode 100644 index 0000000..244413d --- /dev/null +++ b/lib/features/todos/data/repositories/todo_repository_impl.dart @@ -0,0 +1,49 @@ +import 'package:fpdart/fpdart.dart'; +import '../../../../core/errors/exceptions.dart'; +import '../../../../core/errors/failures.dart'; +import '../../../../core/network/network_info.dart'; +import '../../domain/entities/todo.dart'; +import '../../domain/repositories/todo_repository.dart'; +import '../datasources/todo_remote_datasource.dart'; + +class TodoRepositoryImpl implements TodoRepository { + final TodoRemoteDataSource remoteDataSource; + final NetworkInfo networkInfo; + + TodoRepositoryImpl({ + required this.remoteDataSource, + required this.networkInfo, + }); + + @override + Future>> getTodos() async { + if (!await networkInfo.isConnected) { + return const Left(NetworkFailure('No internet connection')); + } + + try { + final todos = await remoteDataSource.getTodos(); + return Right(todos.map((model) => model.toEntity()).toList()); + } on ServerException catch (e) { + return Left(ServerFailure(e.message)); + } catch (e) { + return Left(ServerFailure(e.toString())); + } + } + + @override + Future> refreshTodos() async { + if (!await networkInfo.isConnected) { + return const Left(NetworkFailure('No internet connection')); + } + + try { + await remoteDataSource.getTodos(); + return const Right(null); + } on ServerException catch (e) { + return Left(ServerFailure(e.message)); + } catch (e) { + return Left(ServerFailure(e.toString())); + } + } +} diff --git a/lib/features/todos/domain/entities/todo.dart b/lib/features/todos/domain/entities/todo.dart new file mode 100644 index 0000000..4367e98 --- /dev/null +++ b/lib/features/todos/domain/entities/todo.dart @@ -0,0 +1,44 @@ +import 'package:equatable/equatable.dart'; + +class Todo extends Equatable { + final int id; + final String title; + final String? description; + final bool completed; + final String userId; + final DateTime? createdAt; + final DateTime? updatedAt; + + const Todo({ + required this.id, + required this.title, + this.description, + required this.completed, + required this.userId, + this.createdAt, + this.updatedAt, + }); + + Todo copyWith({ + int? id, + String? title, + String? description, + bool? completed, + String? userId, + DateTime? createdAt, + DateTime? updatedAt, + }) { + return Todo( + id: id ?? this.id, + title: title ?? this.title, + description: description ?? this.description, + completed: completed ?? this.completed, + userId: userId ?? this.userId, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } + + @override + List get props => [id, title, description, completed, userId, createdAt, updatedAt]; +} diff --git a/lib/features/todos/domain/repositories/todo_repository.dart b/lib/features/todos/domain/repositories/todo_repository.dart new file mode 100644 index 0000000..2b29b1b --- /dev/null +++ b/lib/features/todos/domain/repositories/todo_repository.dart @@ -0,0 +1,8 @@ +import 'package:fpdart/fpdart.dart'; +import '../../../../core/errors/failures.dart'; +import '../entities/todo.dart'; + +abstract class TodoRepository { + Future>> getTodos(); + Future> refreshTodos(); +} diff --git a/lib/features/todos/presentation/providers/todo_providers.dart b/lib/features/todos/presentation/providers/todo_providers.dart new file mode 100644 index 0000000..a5ee02d --- /dev/null +++ b/lib/features/todos/presentation/providers/todo_providers.dart @@ -0,0 +1,112 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import '../../../../core/providers/app_providers.dart'; +import '../../../../core/providers/network_providers.dart'; +import '../../data/datasources/todo_remote_datasource.dart'; +import '../../data/repositories/todo_repository_impl.dart'; +import '../../domain/entities/todo.dart'; +import '../../domain/repositories/todo_repository.dart'; + +part 'todo_providers.g.dart'; + +/// Todo Remote DataSource Provider +@riverpod +TodoRemoteDataSource todoRemoteDataSource(TodoRemoteDataSourceRef ref) { + final dioClient = ref.watch(dioClientProvider); + return TodoRemoteDataSourceImpl(dioClient: dioClient); +} + +/// Todo Repository Provider +@riverpod +TodoRepository todoRepository(TodoRepositoryRef ref) { + final remoteDataSource = ref.watch(todoRemoteDataSourceProvider); + final networkInfo = ref.watch(networkInfoProvider); + + return TodoRepositoryImpl( + remoteDataSource: remoteDataSource, + networkInfo: networkInfo, + ); +} + +/// Todos State Provider - Fetches and manages todos list +@riverpod +class Todos extends _$Todos { + @override + Future> build() async { + // Auto-fetch todos when provider is first accessed + return _fetchTodos(); + } + + Future> _fetchTodos() async { + final repository = ref.read(todoRepositoryProvider); + final result = await repository.getTodos(); + + return result.fold( + (failure) => throw Exception(failure.message), + (todos) => todos, + ); + } + + /// Refresh todos from API + Future refresh() async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(() => _fetchTodos()); + } + + /// Toggle todo completion status (local only for now) + void toggleTodo(int id) { + state.whenData((todos) { + final updatedTodos = todos.map((todo) { + if (todo.id == id) { + return todo.copyWith(completed: !todo.completed); + } + return todo; + }).toList(); + + state = AsyncValue.data(updatedTodos); + }); + } +} + +/// Filtered Todos Provider - Filter todos by search query +@riverpod +List filteredTodos(FilteredTodosRef ref, String searchQuery) { + final todosAsync = ref.watch(todosProvider); + + return todosAsync.when( + data: (todos) { + if (searchQuery.isEmpty) { + return todos; + } + return todos.where((todo) { + return todo.title.toLowerCase().contains(searchQuery.toLowerCase()) || + (todo.description?.toLowerCase().contains(searchQuery.toLowerCase()) ?? false); + }).toList(); + }, + loading: () => [], + error: (_, __) => [], + ); +} + +/// Completed Todos Count Provider +@riverpod +int completedTodosCount(CompletedTodosCountRef ref) { + final todosAsync = ref.watch(todosProvider); + + return todosAsync.when( + data: (todos) => todos.where((todo) => todo.completed).length, + loading: () => 0, + error: (_, __) => 0, + ); +} + +/// Pending Todos Count Provider +@riverpod +int pendingTodosCount(PendingTodosCountRef ref) { + final todosAsync = ref.watch(todosProvider); + + return todosAsync.when( + data: (todos) => todos.where((todo) => !todo.completed).length, + loading: () => 0, + error: (_, __) => 0, + ); +} diff --git a/lib/features/todos/presentation/providers/todo_providers.g.dart b/lib/features/todos/presentation/providers/todo_providers.g.dart new file mode 100644 index 0000000..b1f670d --- /dev/null +++ b/lib/features/todos/presentation/providers/todo_providers.g.dart @@ -0,0 +1,259 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'todo_providers.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$todoRemoteDataSourceHash() => + r'10f103aa6cd7de9c9829c3554f317065c7115575'; + +/// Todo Remote DataSource Provider +/// +/// Copied from [todoRemoteDataSource]. +@ProviderFor(todoRemoteDataSource) +final todoRemoteDataSourceProvider = + AutoDisposeProvider.internal( + todoRemoteDataSource, + name: r'todoRemoteDataSourceProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$todoRemoteDataSourceHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef TodoRemoteDataSourceRef = AutoDisposeProviderRef; +String _$todoRepositoryHash() => r'6830b5ede91b11ac04d0a9430cb84a0f2a8d0905'; + +/// Todo Repository Provider +/// +/// Copied from [todoRepository]. +@ProviderFor(todoRepository) +final todoRepositoryProvider = AutoDisposeProvider.internal( + todoRepository, + name: r'todoRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$todoRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef TodoRepositoryRef = AutoDisposeProviderRef; +String _$filteredTodosHash() => r'b814fe45ea117a5f71e9a223c39c2cfb5fcff61a'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +/// Filtered Todos Provider - Filter todos by search query +/// +/// Copied from [filteredTodos]. +@ProviderFor(filteredTodos) +const filteredTodosProvider = FilteredTodosFamily(); + +/// Filtered Todos Provider - Filter todos by search query +/// +/// Copied from [filteredTodos]. +class FilteredTodosFamily extends Family> { + /// Filtered Todos Provider - Filter todos by search query + /// + /// Copied from [filteredTodos]. + const FilteredTodosFamily(); + + /// Filtered Todos Provider - Filter todos by search query + /// + /// Copied from [filteredTodos]. + FilteredTodosProvider call( + String searchQuery, + ) { + return FilteredTodosProvider( + searchQuery, + ); + } + + @override + FilteredTodosProvider getProviderOverride( + covariant FilteredTodosProvider provider, + ) { + return call( + provider.searchQuery, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'filteredTodosProvider'; +} + +/// Filtered Todos Provider - Filter todos by search query +/// +/// Copied from [filteredTodos]. +class FilteredTodosProvider extends AutoDisposeProvider> { + /// Filtered Todos Provider - Filter todos by search query + /// + /// Copied from [filteredTodos]. + FilteredTodosProvider( + String searchQuery, + ) : this._internal( + (ref) => filteredTodos( + ref as FilteredTodosRef, + searchQuery, + ), + from: filteredTodosProvider, + name: r'filteredTodosProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$filteredTodosHash, + dependencies: FilteredTodosFamily._dependencies, + allTransitiveDependencies: + FilteredTodosFamily._allTransitiveDependencies, + searchQuery: searchQuery, + ); + + FilteredTodosProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.searchQuery, + }) : super.internal(); + + final String searchQuery; + + @override + Override overrideWith( + List Function(FilteredTodosRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: FilteredTodosProvider._internal( + (ref) => create(ref as FilteredTodosRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + searchQuery: searchQuery, + ), + ); + } + + @override + AutoDisposeProviderElement> createElement() { + return _FilteredTodosProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is FilteredTodosProvider && other.searchQuery == searchQuery; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, searchQuery.hashCode); + + return _SystemHash.finish(hash); + } +} + +mixin FilteredTodosRef on AutoDisposeProviderRef> { + /// The parameter `searchQuery` of this provider. + String get searchQuery; +} + +class _FilteredTodosProviderElement + extends AutoDisposeProviderElement> with FilteredTodosRef { + _FilteredTodosProviderElement(super.provider); + + @override + String get searchQuery => (origin as FilteredTodosProvider).searchQuery; +} + +String _$completedTodosCountHash() => + r'9905f3fbd8c17b4cd4edde44d34b36e7b4d1f582'; + +/// Completed Todos Count Provider +/// +/// Copied from [completedTodosCount]. +@ProviderFor(completedTodosCount) +final completedTodosCountProvider = AutoDisposeProvider.internal( + completedTodosCount, + name: r'completedTodosCountProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$completedTodosCountHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef CompletedTodosCountRef = AutoDisposeProviderRef; +String _$pendingTodosCountHash() => r'f302d2335102b191a27f5ad628d01f9d1cffea05'; + +/// Pending Todos Count Provider +/// +/// Copied from [pendingTodosCount]. +@ProviderFor(pendingTodosCount) +final pendingTodosCountProvider = AutoDisposeProvider.internal( + pendingTodosCount, + name: r'pendingTodosCountProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$pendingTodosCountHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef PendingTodosCountRef = AutoDisposeProviderRef; +String _$todosHash() => r'2ce152307a44fa5d6173831856732cfe2d082c36'; + +/// Todos State Provider - Fetches and manages todos list +/// +/// Copied from [Todos]. +@ProviderFor(Todos) +final todosProvider = + AutoDisposeAsyncNotifierProvider>.internal( + Todos.new, + name: r'todosProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$todosHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$Todos = AutoDisposeAsyncNotifier>; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/lib/features/todos/presentation/screens/home_screen.dart b/lib/features/todos/presentation/screens/home_screen.dart index a5cd576..5d7ec87 100644 --- a/lib/features/todos/presentation/screens/home_screen.dart +++ b/lib/features/todos/presentation/screens/home_screen.dart @@ -1,110 +1,34 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../providers/todo_providers.dart'; -class HomeScreen extends StatefulWidget { - const HomeScreen({super.key}); +class TodoScreen extends ConsumerStatefulWidget { + const TodoScreen({super.key}); @override - State createState() => _HomeScreenState(); + ConsumerState createState() => _TodoScreenState(); } -class _HomeScreenState extends State { - bool _isLoading = false; - List> _todos = []; +class _TodoScreenState extends ConsumerState { String _searchQuery = ''; final TextEditingController _searchController = TextEditingController(); - @override - void initState() { - super.initState(); - _loadTodos(); - } - @override void dispose() { _searchController.dispose(); super.dispose(); } - // Mock todos data - will be replaced with API call - Future _loadTodos() async { - setState(() { - _isLoading = true; - }); - - // Simulate API call delay - await Future.delayed(const Duration(seconds: 1)); - - // Mock data simulating JSONPlaceholder response - final mockTodos = [ - { - 'id': 1, - 'title': 'Complete project documentation', - 'completed': false, - 'userId': 1, - }, - { - 'id': 2, - 'title': 'Review code changes', - 'completed': true, - 'userId': 1, - }, - { - 'id': 3, - 'title': 'Update Flutter dependencies', - 'completed': false, - 'userId': 1, - }, - { - 'id': 4, - 'title': 'Write unit tests', - 'completed': false, - 'userId': 2, - }, - { - 'id': 5, - 'title': 'Fix navigation bug', - 'completed': true, - 'userId': 2, - }, - ]; - - if (mounted) { - setState(() { - _todos = mockTodos; - _isLoading = false; - }); - } - } - - List> get _filteredTodos { - if (_searchQuery.isEmpty) { - return _todos; - } - return _todos.where((todo) { - return todo['title'] - .toString() - .toLowerCase() - .contains(_searchQuery.toLowerCase()); - }).toList(); - } - - void _toggleTodoStatus(int id) { - setState(() { - final todoIndex = _todos.indexWhere((todo) => todo['id'] == id); - if (todoIndex != -1) { - _todos[todoIndex]['completed'] = !_todos[todoIndex]['completed']; - } - }); - } - Future _refreshTodos() async { - await _loadTodos(); + await ref.read(todosProvider.notifier).refresh(); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; + final todosAsync = ref.watch(todosProvider); + final filteredTodos = ref.watch(filteredTodosProvider(_searchQuery)); return Scaffold( appBar: AppBar( @@ -165,25 +89,61 @@ class _HomeScreenState extends State { // Todos List Expanded( - child: _isLoading - ? const Center( - child: CircularProgressIndicator(), - ) - : _todos.isEmpty - ? _buildEmptyState() - : RefreshIndicator( - onRefresh: _refreshTodos, - child: _filteredTodos.isEmpty - ? _buildNoResultsState() - : ListView.builder( - padding: const EdgeInsets.all(16.0), - itemCount: _filteredTodos.length, - itemBuilder: (context, index) { - final todo = _filteredTodos[index]; - return _buildTodoCard(todo); - }, - ), + child: todosAsync.when( + data: (todos) { + if (todos.isEmpty) { + return _buildEmptyState(); + } + if (filteredTodos.isEmpty) { + return _buildNoResultsState(); + } + return RefreshIndicator( + onRefresh: _refreshTodos, + child: ListView.builder( + padding: const EdgeInsets.all(16.0), + itemCount: filteredTodos.length, + itemBuilder: (context, index) { + final todo = filteredTodos[index]; + return _buildTodoCard(todo); + }, + ), + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, stack) => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + size: 64, + color: colorScheme.error, + ), + const SizedBox(height: 16), + Text( + 'Error loading todos', + style: theme.textTheme.headlineSmall?.copyWith( + color: colorScheme.error, ), + ), + const SizedBox(height: 8), + Text( + error.toString(), + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: _refreshTodos, + icon: const Icon(Icons.refresh), + label: const Text('Retry'), + ), + ], + ), + ), + ), ), ], ), @@ -202,10 +162,10 @@ class _HomeScreenState extends State { ); } - Widget _buildTodoCard(Map todo) { + Widget _buildTodoCard(todo) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; - final isCompleted = todo['completed'] as bool; + final isCompleted = todo.completed; return Card( margin: const EdgeInsets.only(bottom: 8.0), @@ -220,13 +180,13 @@ class _HomeScreenState extends State { ), leading: Checkbox( value: isCompleted, - onChanged: (_) => _toggleTodoStatus(todo['id']), + onChanged: (_) => ref.read(todosProvider.notifier).toggleTodo(todo.id), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(4), ), ), title: Text( - todo['title'], + todo.title, style: theme.textTheme.bodyLarge?.copyWith( decoration: isCompleted ? TextDecoration.lineThrough : null, color: isCompleted @@ -234,11 +194,27 @@ class _HomeScreenState extends State { : colorScheme.onSurface, ), ), - subtitle: Text( - 'ID: ${todo['id']} • User: ${todo['userId']}', - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (todo.description != null && todo.description!.isNotEmpty) + Text( + todo.description!, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + 'ID: ${todo.id}', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant.withOpacity(0.7), + fontSize: 11, + ), + ), + ], ), trailing: PopupMenuButton( onSelected: (value) {