From 514adde721cf3ea812df629cf1e6470613114619 Mon Sep 17 00:00:00 2001 From: PeterChrz Date: Tue, 4 Aug 2026 23:40:54 -0400 Subject: [PATCH] Initial commit: Flutter todo app with Riverpod --- .forgejo/workflows/ci.yml | 35 ++ .gitignore | 29 ++ analysis_options.yaml | 27 ++ lib/app.dart | 23 ++ lib/core/routing/app_router.dart | 31 ++ lib/core/theme/app_colors.dart | 18 ++ lib/core/theme/app_theme.dart | 19 ++ .../in_memory_todo_repository.dart | 89 ++++++ .../domain/entities/create_todo_params.dart | 13 + lib/features/todo/domain/entities/todo.dart | 86 +++++ .../domain/entities/update_todo_params.dart | 17 + .../domain/repositories/todo_repository.dart | 13 + .../providers/todo_providers.dart | 86 +++++ .../screens/todo_detail_screen.dart | 203 ++++++++++++ .../screens/todo_list_screen.dart | 71 +++++ .../widgets/empty_todo_state.dart | 43 +++ .../presentation/widgets/todo_filter_bar.dart | 68 ++++ .../presentation/widgets/todo_list_tile.dart | 78 +++++ lib/main.dart | 12 + pubspec.lock | 298 ++++++++++++++++++ pubspec.yaml | 27 ++ test/unit/todo_providers_test.dart | 118 +++++++ test/unit/todo_repository_test.dart | 183 +++++++++++ test/widget/todo_detail_screen_test.dart | 82 +++++ test/widget/todo_list_screen_test.dart | 100 ++++++ 25 files changed, 1769 insertions(+) create mode 100644 .forgejo/workflows/ci.yml create mode 100644 .gitignore create mode 100644 analysis_options.yaml create mode 100644 lib/app.dart create mode 100644 lib/core/routing/app_router.dart create mode 100644 lib/core/theme/app_colors.dart create mode 100644 lib/core/theme/app_theme.dart create mode 100644 lib/features/todo/data/repositories/in_memory_todo_repository.dart create mode 100644 lib/features/todo/domain/entities/create_todo_params.dart create mode 100644 lib/features/todo/domain/entities/todo.dart create mode 100644 lib/features/todo/domain/entities/update_todo_params.dart create mode 100644 lib/features/todo/domain/repositories/todo_repository.dart create mode 100644 lib/features/todo/presentation/providers/todo_providers.dart create mode 100644 lib/features/todo/presentation/screens/todo_detail_screen.dart create mode 100644 lib/features/todo/presentation/screens/todo_list_screen.dart create mode 100644 lib/features/todo/presentation/widgets/empty_todo_state.dart create mode 100644 lib/features/todo/presentation/widgets/todo_filter_bar.dart create mode 100644 lib/features/todo/presentation/widgets/todo_list_tile.dart create mode 100644 lib/main.dart create mode 100644 pubspec.lock create mode 100644 pubspec.yaml create mode 100644 test/unit/todo_providers_test.dart create mode 100644 test/unit/todo_repository_test.dart create mode 100644 test/widget/todo_detail_screen_test.dart create mode 100644 test/widget/todo_list_screen_test.dart diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml new file mode 100644 index 0000000..44ea127 --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -0,0 +1,35 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + analyze: + name: Static Analysis + runs-on: ubuntu-latest + container: + image: ghcr.io/cirruslabs/flutter:3.24.5 + steps: + - uses: actions/checkout@v4 + - name: Install dependencies + run: flutter pub get + - name: Check formatting + run: dart format --set-exit-if-changed lib/ test/ + - name: Analyze + run: flutter analyze + + test: + name: Tests + runs-on: ubuntu-latest + needs: analyze + container: + image: ghcr.io/cirruslabs/flutter:3.24.5 + steps: + - uses: actions/checkout@v4 + - name: Install dependencies + run: flutter pub get + - name: Run tests + run: flutter test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a901940 --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# Flutter +.dart_tool/ +.packages +build/ +*.iml +.flutter-plugins +.flutter-plugins-dependencies +.metadata + +# Android +android/app/google-services.json +android/key.properties +*.jks +*.keystore + +# iOS +ios/Pods/ +ios/Runner/GoogleService-Info.plist + +# Generated +*.g.dart +*.freezed.dart +*.mocks.dart + +# IDE +.idea/ +.vscode/ +*.swp +*.swo diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..08a7321 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,27 @@ +include: package:flutter_lints/flutter.yaml + +linter: + rules: + - always_declare_return_types + - annotate_overrides + - avoid_empty_else + - avoid_print + - avoid_unnecessary_containers + - avoid_web_libraries_in_flutter + - no_logic_in_create_state + - prefer_const_constructors + - prefer_const_declarations + - prefer_final_locals + - prefer_single_quotes + - sort_child_properties_last + - unawaited_futures + - use_build_context_synchronously + - use_key_in_widget_constructors + +analyzer: + errors: + missing_return: error + dead_code: warning + exclude: + - '**/*.g.dart' + - '**/*.freezed.dart' diff --git a/lib/app.dart b/lib/app.dart new file mode 100644 index 0000000..6f88e94 --- /dev/null +++ b/lib/app.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'core/routing/app_router.dart'; +import 'core/theme/app_theme.dart'; + +class TodoApp extends ConsumerWidget { + const TodoApp({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final router = ref.watch(appRouterProvider); + + return MaterialApp.router( + title: 'Todo App', + theme: AppTheme.light, + darkTheme: AppTheme.dark, + themeMode: ThemeMode.system, + routerConfig: router, + debugShowCheckedModeBanner: false, + ); + } +} diff --git a/lib/core/routing/app_router.dart b/lib/core/routing/app_router.dart new file mode 100644 index 0000000..36f3bc1 --- /dev/null +++ b/lib/core/routing/app_router.dart @@ -0,0 +1,31 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../features/todo/presentation/screens/todo_detail_screen.dart'; +import '../../features/todo/presentation/screens/todo_list_screen.dart'; + +final appRouterProvider = Provider((ref) { + return GoRouter( + initialLocation: '/', + routes: [ + GoRoute( + path: '/', + name: 'todo-list', + builder: (context, state) => const TodoListScreen(), + ), + GoRoute( + path: '/todo/new', + name: 'todo-create', + builder: (context, state) => const TodoDetailScreen(), + ), + GoRoute( + path: '/todo/:id', + name: 'todo-detail', + builder: (context, state) { + final id = state.pathParameters['id']!; + return TodoDetailScreen(todoId: id); + }, + ), + ], + ); +}); diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart new file mode 100644 index 0000000..cb4f4c1 --- /dev/null +++ b/lib/core/theme/app_colors.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; + +class AppColors { + AppColors._(); + + static const priorityHigh = Color(0xFFBA1A1A); + static const priorityMedium = Color(0xFF8A6100); + static const priorityLow = Color(0xFF386A20); + + static Color priorityColor(String priority) { + return switch (priority) { + 'high' => priorityHigh, + 'medium' => priorityMedium, + 'low' => priorityLow, + _ => priorityMedium, + }; + } +} diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart new file mode 100644 index 0000000..0b195b0 --- /dev/null +++ b/lib/core/theme/app_theme.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; + +class AppTheme { + AppTheme._(); + + static const _seedColor = Color(0xFF6750A4); + + static final light = ThemeData( + colorSchemeSeed: _seedColor, + useMaterial3: true, + brightness: Brightness.light, + ); + + static final dark = ThemeData( + colorSchemeSeed: _seedColor, + useMaterial3: true, + brightness: Brightness.dark, + ); +} diff --git a/lib/features/todo/data/repositories/in_memory_todo_repository.dart b/lib/features/todo/data/repositories/in_memory_todo_repository.dart new file mode 100644 index 0000000..ebeb172 --- /dev/null +++ b/lib/features/todo/data/repositories/in_memory_todo_repository.dart @@ -0,0 +1,89 @@ +import 'package:uuid/uuid.dart'; + +import '../../domain/entities/todo.dart'; +import '../../domain/entities/create_todo_params.dart'; +import '../../domain/entities/update_todo_params.dart'; +import '../../domain/repositories/todo_repository.dart'; + +class InMemoryTodoRepository implements TodoRepository { + final _uuid = const Uuid(); + final List _todos = []; + + @override + Future> getTodos({String? priorityFilter, bool? completedFilter}) async { + var result = List.from(_todos); + + if (priorityFilter != null) { + result = result.where((t) => t.priority == priorityFilter).toList(); + } + if (completedFilter != null) { + result = result.where((t) => t.isCompleted == completedFilter).toList(); + } + + result.sort((a, b) => a.sortOrder.compareTo(b.sortOrder)); + return result; + } + + @override + Future getTodo(String id) async { + final index = _todos.indexWhere((t) => t.id == id); + if (index == -1) throw Exception('Todo not found: $id'); + return _todos[index]; + } + + @override + Future createTodo(CreateTodoParams params) async { + final now = DateTime.now(); + final todo = Todo( + id: _uuid.v4(), + title: params.title, + description: params.description, + priority: params.priority, + dueDate: params.dueDate, + sortOrder: _todos.length, + createdAt: now, + updatedAt: now, + ); + _todos.add(todo); + return todo; + } + + @override + Future updateTodo(String id, UpdateTodoParams params) async { + final index = _todos.indexWhere((t) => t.id == id); + if (index == -1) throw Exception('Todo not found: $id'); + + final existing = _todos[index]; + final updated = existing.copyWith( + title: params.title ?? existing.title, + description: params.description, + isCompleted: params.isCompleted ?? existing.isCompleted, + priority: params.priority ?? existing.priority, + dueDate: params.dueDate, + sortOrder: params.sortOrder ?? existing.sortOrder, + updatedAt: DateTime.now(), + ); + _todos[index] = updated; + return updated; + } + + @override + Future deleteTodo(String id) async { + _todos.removeWhere((t) => t.id == id); + } + + @override + Future toggleTodo(String id) async { + final todo = await getTodo(id); + return updateTodo(id, UpdateTodoParams(isCompleted: !todo.isCompleted)); + } + + @override + Future reorderTodos(int oldIndex, int newIndex) async { + final item = _todos.removeAt(oldIndex); + _todos.insert(newIndex, item); + for (var i = 0; i < _todos.length; i++) { + _todos[i] = _todos[i].copyWith(sortOrder: i); + } + } +} diff --git a/lib/features/todo/domain/entities/create_todo_params.dart b/lib/features/todo/domain/entities/create_todo_params.dart new file mode 100644 index 0000000..dc12547 --- /dev/null +++ b/lib/features/todo/domain/entities/create_todo_params.dart @@ -0,0 +1,13 @@ +class CreateTodoParams { + final String title; + final String? description; + final String priority; + final DateTime? dueDate; + + const CreateTodoParams({ + required this.title, + this.description, + this.priority = 'medium', + this.dueDate, + }); +} diff --git a/lib/features/todo/domain/entities/todo.dart b/lib/features/todo/domain/entities/todo.dart new file mode 100644 index 0000000..86deebc --- /dev/null +++ b/lib/features/todo/domain/entities/todo.dart @@ -0,0 +1,86 @@ +class Todo { + final String id; + final String title; + final String? description; + final bool isCompleted; + final String priority; + final DateTime? dueDate; + final int sortOrder; + final DateTime? createdAt; + final DateTime? updatedAt; + + const Todo({ + required this.id, + required this.title, + this.description, + this.isCompleted = false, + this.priority = 'medium', + this.dueDate, + this.sortOrder = 0, + this.createdAt, + this.updatedAt, + }); + + Todo copyWith({ + String? id, + String? title, + String? description, + bool? isCompleted, + String? priority, + DateTime? dueDate, + int? sortOrder, + DateTime? createdAt, + DateTime? updatedAt, + }) { + return Todo( + id: id ?? this.id, + title: title ?? this.title, + description: description ?? this.description, + isCompleted: isCompleted ?? this.isCompleted, + priority: priority ?? this.priority, + dueDate: dueDate ?? this.dueDate, + sortOrder: sortOrder ?? this.sortOrder, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } + + Map toJson() { + return { + 'id': id, + 'title': title, + 'description': description, + 'isCompleted': isCompleted, + 'priority': priority, + 'dueDate': dueDate?.toIso8601String(), + 'sortOrder': sortOrder, + 'createdAt': createdAt?.toIso8601String(), + 'updatedAt': updatedAt?.toIso8601String(), + }; + } + + factory Todo.fromJson(Map json) { + return Todo( + id: json['id'] as String, + title: json['title'] as String, + description: json['description'] as String?, + isCompleted: json['isCompleted'] as bool? ?? false, + priority: json['priority'] as String? ?? 'medium', + dueDate: json['dueDate'] != null ? DateTime.parse(json['dueDate'] as String) : null, + sortOrder: json['sortOrder'] as int? ?? 0, + createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt'] as String) : null, + updatedAt: json['updatedAt'] != null ? DateTime.parse(json['updatedAt'] as String) : null, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is Todo && id == other.id; + + @override + int get hashCode => id.hashCode; + + @override + String toString() => 'Todo(id: $id, title: $title, isCompleted: $isCompleted)'; +} diff --git a/lib/features/todo/domain/entities/update_todo_params.dart b/lib/features/todo/domain/entities/update_todo_params.dart new file mode 100644 index 0000000..4cbfb40 --- /dev/null +++ b/lib/features/todo/domain/entities/update_todo_params.dart @@ -0,0 +1,17 @@ +class UpdateTodoParams { + final String? title; + final String? description; + final bool? isCompleted; + final String? priority; + final DateTime? dueDate; + final int? sortOrder; + + const UpdateTodoParams({ + this.title, + this.description, + this.isCompleted, + this.priority, + this.dueDate, + this.sortOrder, + }); +} diff --git a/lib/features/todo/domain/repositories/todo_repository.dart b/lib/features/todo/domain/repositories/todo_repository.dart new file mode 100644 index 0000000..821ac19 --- /dev/null +++ b/lib/features/todo/domain/repositories/todo_repository.dart @@ -0,0 +1,13 @@ +import '../entities/todo.dart'; +import '../entities/create_todo_params.dart'; +import '../entities/update_todo_params.dart'; + +abstract class TodoRepository { + Future> getTodos({String? priorityFilter, bool? completedFilter}); + Future getTodo(String id); + Future createTodo(CreateTodoParams params); + Future updateTodo(String id, UpdateTodoParams params); + Future deleteTodo(String id); + Future toggleTodo(String id); + Future reorderTodos(int oldIndex, int newIndex); +} diff --git a/lib/features/todo/presentation/providers/todo_providers.dart b/lib/features/todo/presentation/providers/todo_providers.dart new file mode 100644 index 0000000..04e9ec1 --- /dev/null +++ b/lib/features/todo/presentation/providers/todo_providers.dart @@ -0,0 +1,86 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../data/repositories/in_memory_todo_repository.dart'; +import '../../domain/entities/todo.dart'; +import '../../domain/entities/create_todo_params.dart'; +import '../../domain/entities/update_todo_params.dart'; +import '../../domain/repositories/todo_repository.dart'; + +final todoRepositoryProvider = Provider((ref) { + return InMemoryTodoRepository(); +}); + +final todoListProvider = + AsyncNotifierProvider>( + TodoListNotifier.new, +); + +final todoDetailProvider = + FutureProvider.family((ref, id) { + return ref.watch(todoRepositoryProvider).getTodo(id); +}); + +final todoFilterProvider = StateProvider((ref) { + return const TodoFilter(); +}); + +class TodoFilter { + final String? priority; + final bool? completed; + + const TodoFilter({this.priority, this.completed}); + + TodoFilter copyWith({String? priority, bool? completed, bool clearPriority = false, bool clearCompleted = false}) { + return TodoFilter( + priority: clearPriority ? null : (priority ?? this.priority), + completed: clearCompleted ? null : (completed ?? this.completed), + ); + } +} + +class TodoListNotifier extends AsyncNotifier> { + @override + Future> build() async { + final repository = ref.watch(todoRepositoryProvider); + final filter = ref.watch(todoFilterProvider); + return repository.getTodos( + priorityFilter: filter.priority, + completedFilter: filter.completed, + ); + } + + Future createTodo(CreateTodoParams params) async { + final repository = ref.read(todoRepositoryProvider); + await repository.createTodo(params); + ref.invalidateSelf(); + } + + Future updateTodo(String id, UpdateTodoParams params) async { + final repository = ref.read(todoRepositoryProvider); + await repository.updateTodo(id, params); + ref.invalidateSelf(); + } + + Future deleteTodo(String id) async { + final repository = ref.read(todoRepositoryProvider); + await repository.deleteTodo(id); + ref.invalidateSelf(); + } + + Future toggleTodo(String id) async { + final repository = ref.read(todoRepositoryProvider); + await repository.toggleTodo(id); + ref.invalidateSelf(); + } + + Future reorderTodos(int oldIndex, int newIndex) async { + final repository = ref.read(todoRepositoryProvider); + await repository.reorderTodos(oldIndex, newIndex); + ref.invalidateSelf(); + } + + void setFilter(TodoFilter filter) { + ref.read(todoFilterProvider.notifier).state = filter; + ref.invalidateSelf(); + } +} diff --git a/lib/features/todo/presentation/screens/todo_detail_screen.dart b/lib/features/todo/presentation/screens/todo_detail_screen.dart new file mode 100644 index 0000000..eb5f75e --- /dev/null +++ b/lib/features/todo/presentation/screens/todo_detail_screen.dart @@ -0,0 +1,203 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../domain/entities/create_todo_params.dart'; +import '../../domain/entities/update_todo_params.dart'; +import '../providers/todo_providers.dart'; + +class TodoDetailScreen extends ConsumerStatefulWidget { + final String? todoId; + + const TodoDetailScreen({super.key, this.todoId}); + + @override + ConsumerState createState() => _TodoDetailScreenState(); +} + +class _TodoDetailScreenState extends ConsumerState { + final _formKey = GlobalKey(); + late final TextEditingController _titleController; + late final TextEditingController _descriptionController; + String _priority = 'medium'; + DateTime? _dueDate; + bool _isLoading = false; + + bool get isEditing => widget.todoId != null; + + @override + void initState() { + super.initState(); + _titleController = TextEditingController(); + _descriptionController = TextEditingController(); + + if (isEditing) { + _loadTodo(); + } + } + + Future _loadTodo() async { + final todoAsync = ref.read(todoDetailProvider(widget.todoId!)); + todoAsync.whenData((todo) { + _titleController.text = todo.title; + _descriptionController.text = todo.description ?? ''; + _priority = todo.priority; + _dueDate = todo.dueDate; + }); + } + + @override + void dispose() { + _titleController.dispose(); + _descriptionController.dispose(); + super.dispose(); + } + + Future _save() async { + if (!_formKey.currentState!.validate()) return; + + setState(() => _isLoading = true); + + try { + if (isEditing) { + await ref.read(todoListProvider.notifier).updateTodo( + widget.todoId!, + UpdateTodoParams( + title: _titleController.text, + description: _descriptionController.text.isEmpty + ? null + : _descriptionController.text, + priority: _priority, + dueDate: _dueDate, + ), + ); + } else { + await ref.read(todoListProvider.notifier).createTodo( + CreateTodoParams( + title: _titleController.text, + description: _descriptionController.text.isEmpty + ? null + : _descriptionController.text, + priority: _priority, + dueDate: _dueDate, + ), + ); + } + + if (mounted) context.pop(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to save: $e')), + ); + } + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + Future _pickDueDate() async { + final date = await showDatePicker( + context: context, + initialDate: _dueDate ?? DateTime.now().add(const Duration(days: 1)), + firstDate: DateTime.now(), + lastDate: DateTime.now().add(const Duration(days: 365)), + ); + if (date != null) { + setState(() => _dueDate = date); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Scaffold( + appBar: AppBar( + title: Text(isEditing ? 'Edit Todo' : 'New Todo'), + ), + body: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + TextFormField( + controller: _titleController, + decoration: const InputDecoration( + labelText: 'Title', + border: OutlineInputBorder(), + ), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Title is required'; + } + return null; + }, + autofocus: !isEditing, + ), + const SizedBox(height: 16), + TextFormField( + controller: _descriptionController, + decoration: const InputDecoration( + labelText: 'Description (optional)', + border: OutlineInputBorder(), + ), + maxLines: 3, + ), + const SizedBox(height: 16), + DropdownButtonFormField( + initialValue: _priority, + decoration: const InputDecoration( + labelText: 'Priority', + border: OutlineInputBorder(), + ), + items: const [ + DropdownMenuItem(value: 'low', child: Text('Low')), + DropdownMenuItem(value: 'medium', child: Text('Medium')), + DropdownMenuItem(value: 'high', child: Text('High')), + ], + onChanged: (value) { + if (value != null) setState(() => _priority = value); + }, + ), + const SizedBox(height: 16), + InkWell( + onTap: _pickDueDate, + child: InputDecorator( + decoration: const InputDecoration( + labelText: 'Due Date (optional)', + border: OutlineInputBorder(), + suffixIcon: Icon(Icons.calendar_today), + ), + child: Text( + _dueDate != null + ? '${_dueDate!.year}-${_dueDate!.month.toString().padLeft(2, '0')}-${_dueDate!.day.toString().padLeft(2, '0')}' + : 'No date selected', + style: theme.textTheme.bodyLarge?.copyWith( + color: _dueDate != null ? null : theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ), + if (_dueDate != null) + TextButton( + onPressed: () => setState(() => _dueDate = null), + child: const Text('Clear date'), + ), + const SizedBox(height: 24), + FilledButton( + onPressed: _isLoading ? null : _save, + child: _isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(isEditing ? 'Save Changes' : 'Create Todo'), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/todo/presentation/screens/todo_list_screen.dart b/lib/features/todo/presentation/screens/todo_list_screen.dart new file mode 100644 index 0000000..b3b2eb2 --- /dev/null +++ b/lib/features/todo/presentation/screens/todo_list_screen.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../providers/todo_providers.dart'; +import '../widgets/todo_list_tile.dart'; +import '../widgets/todo_filter_bar.dart'; +import '../widgets/empty_todo_state.dart'; + +class TodoListScreen extends ConsumerWidget { + const TodoListScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final todosAsync = ref.watch(todoListProvider); + final theme = Theme.of(context); + + return Scaffold( + appBar: AppBar( + title: const Text('Todos'), + centerTitle: false, + ), + body: Column( + children: [ + const TodoFilterBar(), + Expanded( + child: todosAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline, size: 48, color: theme.colorScheme.error), + const SizedBox(height: 8), + Text('Something went wrong', style: theme.textTheme.bodyLarge), + const SizedBox(height: 16), + FilledButton.tonal( + onPressed: () => ref.invalidate(todoListProvider), + child: const Text('Retry'), + ), + ], + ), + ), + data: (todos) { + if (todos.isEmpty) { + return const EmptyTodoState(); + } + return ReorderableListView.builder( + itemCount: todos.length, + onReorderItem: (oldIndex, newIndex) { + ref.read(todoListProvider.notifier).reorderTodos(oldIndex, newIndex); + }, + itemBuilder: (context, index) { + return TodoListTile( + key: ValueKey(todos[index].id), + todo: todos[index], + ); + }, + ); + }, + ), + ), + ], + ), + floatingActionButton: FloatingActionButton( + onPressed: () => context.go('/todo/new'), + child: const Icon(Icons.add), + ), + ); + } +} diff --git a/lib/features/todo/presentation/widgets/empty_todo_state.dart b/lib/features/todo/presentation/widgets/empty_todo_state.dart new file mode 100644 index 0000000..6fd6d62 --- /dev/null +++ b/lib/features/todo/presentation/widgets/empty_todo_state.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +class EmptyTodoState extends StatelessWidget { + const EmptyTodoState({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.checklist_outlined, + size: 64, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text( + 'No todos yet', + style: theme.textTheme.titleMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Text( + 'Tap + to create your first todo', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 24), + FilledButton.tonal( + onPressed: () => context.push('/todo/new'), + child: const Text('Create Todo'), + ), + ], + ), + ); + } +} diff --git a/lib/features/todo/presentation/widgets/todo_filter_bar.dart b/lib/features/todo/presentation/widgets/todo_filter_bar.dart new file mode 100644 index 0000000..532d89d --- /dev/null +++ b/lib/features/todo/presentation/widgets/todo_filter_bar.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../providers/todo_providers.dart'; + +class TodoFilterBar extends ConsumerWidget { + const TodoFilterBar({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final filter = ref.watch(todoFilterProvider); + final theme = Theme.of(context); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + FilterChip( + label: const Text('All'), + selected: filter.priority == null && filter.completed == null, + onSelected: (_) { + ref.read(todoListProvider.notifier).setFilter(const TodoFilter()); + }, + ), + const SizedBox(width: 8), + FilterChip( + label: const Text('Active'), + selected: filter.completed == false, + onSelected: (_) { + ref.read(todoListProvider.notifier).setFilter( + filter.copyWith(completed: false, clearPriority: true), + ); + }, + ), + const SizedBox(width: 8), + FilterChip( + label: const Text('Completed'), + selected: filter.completed == true, + onSelected: (_) { + ref.read(todoListProvider.notifier).setFilter( + filter.copyWith(completed: true, clearPriority: true), + ); + }, + ), + const Spacer(), + PopupMenuButton( + icon: Icon( + Icons.filter_list, + color: filter.priority != null + ? theme.colorScheme.primary + : theme.colorScheme.onSurfaceVariant, + ), + onSelected: (priority) { + ref.read(todoListProvider.notifier).setFilter( + filter.copyWith(priority: priority, clearCompleted: true), + ); + }, + itemBuilder: (context) => [ + const PopupMenuItem(value: 'high', child: Text('High Priority')), + const PopupMenuItem(value: 'medium', child: Text('Medium Priority')), + const PopupMenuItem(value: 'low', child: Text('Low Priority')), + ], + ), + ], + ), + ); + } +} diff --git a/lib/features/todo/presentation/widgets/todo_list_tile.dart b/lib/features/todo/presentation/widgets/todo_list_tile.dart new file mode 100644 index 0000000..f2cea14 --- /dev/null +++ b/lib/features/todo/presentation/widgets/todo_list_tile.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../../core/theme/app_colors.dart'; +import '../../domain/entities/todo.dart'; +import '../providers/todo_providers.dart'; + +class TodoListTile extends ConsumerWidget { + final Todo todo; + + const TodoListTile({super.key, required this.todo}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final priorityColor = AppColors.priorityColor(todo.priority); + + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: ListTile( + leading: Checkbox( + value: todo.isCompleted, + onChanged: (_) { + ref.read(todoListProvider.notifier).toggleTodo(todo.id); + }, + ), + title: Text( + todo.title, + style: theme.textTheme.titleMedium?.copyWith( + decoration: todo.isCompleted ? TextDecoration.lineThrough : null, + color: todo.isCompleted ? theme.colorScheme.onSurfaceVariant : null, + ), + ), + subtitle: Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: priorityColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + todo.priority.toUpperCase(), + style: theme.textTheme.labelSmall?.copyWith( + color: priorityColor, + fontWeight: FontWeight.bold, + ), + ), + ), + if (todo.dueDate != null) ...[ + const SizedBox(width: 8), + Icon( + Icons.calendar_today, + size: 14, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 4), + Text( + '${todo.dueDate!.month}/${todo.dueDate!.day}', + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + trailing: IconButton( + icon: const Icon(Icons.delete_outline), + onPressed: () { + ref.read(todoListProvider.notifier).deleteTodo(todo.id); + }, + ), + onTap: () => context.push('/todo/${todo.id}'), + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..e1af665 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,12 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'app.dart'; + +void main() { + runApp( + const ProviderScope( + child: TodoApp(), + ), + ); +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..7e64055 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,298 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3 + url: "https://pub.dev" + source: hosted + version: "14.8.1" + intl: + dependency: "direct main" + description: + name: intl + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + url: "https://pub.dev" + source: hosted + version: "0.19.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.dev" + source: hosted + version: "5.1.1" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + url: "https://pub.dev" + source: hosted + version: "0.12.20" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" + url: "https://pub.dev" + source: hosted + version: "1.19.0" + mocktail: + dependency: "direct dev" + description: + name: mocktail + sha256: "5e1bf53cc7baa8062a33b84424deb61513858ea05c601b8509e683815b5914aa" + url: "https://pub.dev" + source: hosted + version: "1.0.5" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.dev" + source: hosted + version: "1.0.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + url: "https://pub.dev" + source: hosted + version: "0.7.12" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + uuid: + dependency: "direct main" + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.dev" + source: hosted + version: "4.6.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.dev" + source: hosted + version: "15.2.0" +sdks: + dart: ">=3.10.0 <4.0.0" + flutter: ">=3.24.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..b24bac4 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,27 @@ +name: todo_app +description: A simple todo app built with Flutter and Riverpod. +publish_to: 'none' +version: 1.0.0+1 + +environment: + sdk: '>=3.5.0 <4.0.0' + flutter: '>=3.24.0' + +dependencies: + flutter: + sdk: flutter + flutter_riverpod: ^2.6.1 + go_router: ^14.6.2 + uuid: ^4.5.1 + intl: ^0.19.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^5.0.0 + mocktail: ^1.0.4 + +flutter: + uses-material-design: true + assets: + - assets/images/ diff --git a/test/unit/todo_providers_test.dart b/test/unit/todo_providers_test.dart new file mode 100644 index 0000000..adb1224 --- /dev/null +++ b/test/unit/todo_providers_test.dart @@ -0,0 +1,118 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:todo_app/features/todo/domain/entities/todo.dart'; +import 'package:todo_app/features/todo/domain/entities/create_todo_params.dart'; +import 'package:todo_app/features/todo/domain/repositories/todo_repository.dart'; +import 'package:todo_app/features/todo/presentation/providers/todo_providers.dart'; + +class MockTodoRepository extends Mock implements TodoRepository {} + +class FakeCreateTodoParams extends Fake implements CreateTodoParams {} + +void main() { + late MockTodoRepository repository; + late ProviderContainer container; + + final testTodo = Todo( + id: '1', + title: 'Test Todo', + priority: 'medium', + sortOrder: 0, + createdAt: DateTime(2026, 1, 1), + updatedAt: DateTime(2026, 1, 1), + ); + + setUpAll(() { + registerFallbackValue(FakeCreateTodoParams()); + }); + + setUp(() { + repository = MockTodoRepository(); + container = ProviderContainer(overrides: [ + todoRepositoryProvider.overrideWithValue(repository), + ]); + addTearDown(container.dispose); + }); + + group('todoListProvider', () { + test('builds with todo list from repository', () async { + when(() => repository.getTodos( + priorityFilter: any(named: 'priorityFilter'), + completedFilter: any(named: 'completedFilter'), + )).thenAnswer((_) async => [testTodo]); + + await container.read(todoListProvider.future); + + expect(container.read(todoListProvider).value, equals([testTodo])); + }); + + test('handles error state', () async { + when(() => repository.getTodos( + priorityFilter: any(named: 'priorityFilter'), + completedFilter: any(named: 'completedFilter'), + )).thenThrow(Exception('Failed')); + + try { + await container.read(todoListProvider.future); + fail('Should have thrown'); + } catch (_) { + expect(container.read(todoListProvider).hasError, isTrue); + } + }); + }); + + group('todoDetailProvider', () { + test('returns todo by id', () async { + when(() => repository.getTodo('1')).thenAnswer((_) async => testTodo); + + final result = await container.read(todoDetailProvider('1').future); + + expect(result, equals(testTodo)); + }); + }); + + group('TodoListNotifier.createTodo', () { + test('creates todo and refreshes list', () async { + when(() => repository.getTodos( + priorityFilter: any(named: 'priorityFilter'), + completedFilter: any(named: 'completedFilter'), + )).thenAnswer((_) async => [testTodo]); + when(() => repository.createTodo(any())).thenAnswer((_) async => testTodo); + + await container.read(todoListProvider.notifier).createTodo( + const CreateTodoParams(title: 'New'), + ); + + verify(() => repository.createTodo(any())).called(1); + }); + }); + + group('TodoListNotifier.toggleTodo', () { + test('toggles and refreshes', () async { + when(() => repository.getTodos( + priorityFilter: any(named: 'priorityFilter'), + completedFilter: any(named: 'completedFilter'), + )).thenAnswer((_) async => [testTodo]); + when(() => repository.toggleTodo('1')).thenAnswer((_) async => testTodo); + + await container.read(todoListProvider.notifier).toggleTodo('1'); + + verify(() => repository.toggleTodo('1')).called(1); + }); + }); + + group('TodoListNotifier.deleteTodo', () { + test('deletes and refreshes', () async { + when(() => repository.getTodos( + priorityFilter: any(named: 'priorityFilter'), + completedFilter: any(named: 'completedFilter'), + )).thenAnswer((_) async => []); + when(() => repository.deleteTodo('1')).thenAnswer((_) async {}); + + await container.read(todoListProvider.notifier).deleteTodo('1'); + + verify(() => repository.deleteTodo('1')).called(1); + }); + }); +} diff --git a/test/unit/todo_repository_test.dart b/test/unit/todo_repository_test.dart new file mode 100644 index 0000000..6394a82 --- /dev/null +++ b/test/unit/todo_repository_test.dart @@ -0,0 +1,183 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:todo_app/features/todo/data/repositories/in_memory_todo_repository.dart'; +import 'package:todo_app/features/todo/domain/entities/create_todo_params.dart'; +import 'package:todo_app/features/todo/domain/entities/update_todo_params.dart'; + +void main() { + late InMemoryTodoRepository repository; + + setUp(() { + repository = InMemoryTodoRepository(); + }); + + group('createTodo', () { + test('creates a todo with required fields', () async { + final todo = await repository.createTodo( + const CreateTodoParams(title: 'Test Todo'), + ); + + expect(todo.title, equals('Test Todo')); + expect(todo.isCompleted, isFalse); + expect(todo.priority, equals('medium')); + expect(todo.id, isNotEmpty); + expect(todo.createdAt, isNotNull); + }); + + test('creates a todo with all fields', () async { + final dueDate = DateTime(2026, 12, 31); + final todo = await repository.createTodo( + CreateTodoParams( + title: 'Full Todo', + description: 'A description', + priority: 'high', + dueDate: dueDate, + ), + ); + + expect(todo.title, equals('Full Todo')); + expect(todo.description, equals('A description')); + expect(todo.priority, equals('high')); + expect(todo.dueDate, equals(dueDate)); + }); + }); + + group('getTodos', () { + test('returns empty list initially', () async { + final todos = await repository.getTodos(); + expect(todos, isEmpty); + }); + + test('returns all todos sorted by sortOrder', () async { + await repository.createTodo(const CreateTodoParams(title: 'B')); + await repository.createTodo(const CreateTodoParams(title: 'A')); + + final todos = await repository.getTodos(); + expect(todos.length, equals(2)); + expect(todos[0].title, equals('B')); + expect(todos[1].title, equals('A')); + }); + + test('filters by priority', () async { + await repository.createTodo( + const CreateTodoParams(title: 'High', priority: 'high'), + ); + await repository.createTodo( + const CreateTodoParams(title: 'Low', priority: 'low'), + ); + + final highTodos = await repository.getTodos(priorityFilter: 'high'); + expect(highTodos.length, equals(1)); + expect(highTodos.first.title, equals('High')); + }); + + test('filters by completion status', () async { + final todo = await repository.createTodo( + const CreateTodoParams(title: 'Done'), + ); + await repository.createTodo( + const CreateTodoParams(title: 'Pending'), + ); + await repository.toggleTodo(todo.id); + + final completed = await repository.getTodos(completedFilter: true); + expect(completed.length, equals(1)); + expect(completed.first.title, equals('Done')); + }); + }); + + group('getTodo', () { + test('returns todo by id', () async { + final created = await repository.createTodo( + const CreateTodoParams(title: 'Find Me'), + ); + + final found = await repository.getTodo(created.id); + expect(found.title, equals('Find Me')); + }); + + test('throws when not found', () async { + expect( + () => repository.getTodo('nonexistent'), + throwsA(isA()), + ); + }); + }); + + group('updateTodo', () { + test('updates todo fields', () async { + final created = await repository.createTodo( + const CreateTodoParams(title: 'Original'), + ); + + final updated = await repository.updateTodo( + created.id, + const UpdateTodoParams(title: 'Updated', priority: 'high'), + ); + + expect(updated.title, equals('Updated')); + expect(updated.priority, equals('high')); + expect(updated.updatedAt, isNotNull); + }); + + test('partial update preserves other fields', () async { + final created = await repository.createTodo( + const CreateTodoParams(title: 'Original', priority: 'low'), + ); + + final updated = await repository.updateTodo( + created.id, + const UpdateTodoParams(title: 'Renamed'), + ); + + expect(updated.title, equals('Renamed')); + expect(updated.priority, equals('low')); + }); + }); + + group('toggleTodo', () { + test('toggles completion status', () async { + final created = await repository.createTodo( + const CreateTodoParams(title: 'Toggle'), + ); + + expect(created.isCompleted, isFalse); + + final toggled = await repository.toggleTodo(created.id); + expect(toggled.isCompleted, isTrue); + + final untoggled = await repository.toggleTodo(created.id); + expect(untoggled.isCompleted, isFalse); + }); + }); + + group('deleteTodo', () { + test('removes todo', () async { + final created = await repository.createTodo( + const CreateTodoParams(title: 'Delete Me'), + ); + + await repository.deleteTodo(created.id); + + final todos = await repository.getTodos(); + expect(todos, isEmpty); + }); + }); + + group('reorderTodos', () { + test('reorders todos and updates sortOrder', () async { + await repository.createTodo(const CreateTodoParams(title: 'A')); + await repository.createTodo(const CreateTodoParams(title: 'B')); + await repository.createTodo(const CreateTodoParams(title: 'C')); + + await repository.reorderTodos(0, 2); + + final todos = await repository.getTodos(); + expect(todos[0].title, equals('B')); + expect(todos[1].title, equals('C')); + expect(todos[2].title, equals('A')); + expect(todos[0].sortOrder, equals(0)); + expect(todos[1].sortOrder, equals(1)); + expect(todos[2].sortOrder, equals(2)); + }); + }); +} diff --git a/test/widget/todo_detail_screen_test.dart b/test/widget/todo_detail_screen_test.dart new file mode 100644 index 0000000..2d2966a --- /dev/null +++ b/test/widget/todo_detail_screen_test.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:todo_app/features/todo/domain/entities/todo.dart'; +import 'package:todo_app/features/todo/domain/repositories/todo_repository.dart'; +import 'package:todo_app/features/todo/presentation/providers/todo_providers.dart'; +import 'package:todo_app/features/todo/presentation/screens/todo_detail_screen.dart'; + +class MockTodoRepository extends Mock implements TodoRepository {} + +void main() { + late MockTodoRepository repository; + + final testTodo = Todo( + id: '1', + title: 'Existing Todo', + description: 'A description', + priority: 'high', + sortOrder: 0, + createdAt: DateTime(2026, 1, 1), + updatedAt: DateTime(2026, 1, 1), + ); + + Widget createCreateWidget() { + return ProviderScope( + overrides: [ + todoRepositoryProvider.overrideWithValue(repository), + ], + child: const MaterialApp( + home: TodoDetailScreen(), + ), + ); + } + + Widget createEditWidget() { + return ProviderScope( + overrides: [ + todoRepositoryProvider.overrideWithValue(repository), + ], + child: const MaterialApp( + home: TodoDetailScreen(todoId: '1'), + ), + ); + } + + setUp(() { + repository = MockTodoRepository(); + }); + + group('Create mode', () { + testWidgets('shows create form', (tester) async { + await tester.pumpWidget(createCreateWidget()); + await tester.pumpAndSettle(); + + expect(find.text('New Todo'), findsOneWidget); + expect(find.text('Create Todo'), findsOneWidget); + }); + + testWidgets('validates title is required', (tester) async { + await tester.pumpWidget(createCreateWidget()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Create Todo')); + await tester.pumpAndSettle(); + + expect(find.text('Title is required'), findsOneWidget); + }); + }); + + group('Edit mode', () { + testWidgets('loads existing todo data', (tester) async { + when(() => repository.getTodo('1')).thenAnswer((_) async => testTodo); + + await tester.pumpWidget(createEditWidget()); + await tester.pump(); + await tester.pump(); + + expect(find.text('Edit Todo'), findsOneWidget); + }); + }); +} diff --git a/test/widget/todo_list_screen_test.dart b/test/widget/todo_list_screen_test.dart new file mode 100644 index 0000000..fd3b10d --- /dev/null +++ b/test/widget/todo_list_screen_test.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:todo_app/features/todo/domain/entities/todo.dart'; +import 'package:todo_app/features/todo/domain/repositories/todo_repository.dart'; +import 'package:todo_app/features/todo/presentation/providers/todo_providers.dart'; +import 'package:todo_app/features/todo/presentation/screens/todo_list_screen.dart'; + +class MockTodoRepository extends Mock implements TodoRepository {} + +void main() { + late MockTodoRepository repository; + + final testTodo = Todo( + id: '1', + title: 'Test Todo', + priority: 'medium', + sortOrder: 0, + createdAt: DateTime(2026, 1, 1), + updatedAt: DateTime(2026, 1, 1), + ); + + Widget createWidgetUnderTest() { + return ProviderScope( + overrides: [ + todoRepositoryProvider.overrideWithValue(repository), + ], + child: const MaterialApp( + home: TodoListScreen(), + ), + ); + } + + setUp(() { + repository = MockTodoRepository(); + }); + + testWidgets('shows loading indicator initially', (tester) async { + when(() => repository.getTodos( + priorityFilter: any(named: 'priorityFilter'), + completedFilter: any(named: 'completedFilter'), + )).thenAnswer((_) async => [testTodo]); + + await tester.pumpWidget(createWidgetUnderTest()); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('shows todo list when loaded', (tester) async { + when(() => repository.getTodos( + priorityFilter: any(named: 'priorityFilter'), + completedFilter: any(named: 'completedFilter'), + )).thenAnswer((_) async => [testTodo]); + + await tester.pumpWidget(createWidgetUnderTest()); + await tester.pumpAndSettle(); + + expect(find.text('Test Todo'), findsOneWidget); + expect(find.text('MEDIUM'), findsOneWidget); + }); + + testWidgets('shows empty state when no todos', (tester) async { + when(() => repository.getTodos( + priorityFilter: any(named: 'priorityFilter'), + completedFilter: any(named: 'completedFilter'), + )).thenAnswer((_) async => []); + + await tester.pumpWidget(createWidgetUnderTest()); + await tester.pumpAndSettle(); + + expect(find.text('No todos yet'), findsOneWidget); + expect(find.text('Create Todo'), findsOneWidget); + }); + + testWidgets('shows error state with retry button', (tester) async { + when(() => repository.getTodos( + priorityFilter: any(named: 'priorityFilter'), + completedFilter: any(named: 'completedFilter'), + )).thenThrow(Exception('Failed')); + + await tester.pumpWidget(createWidgetUnderTest()); + await tester.pumpAndSettle(); + + expect(find.text('Something went wrong'), findsOneWidget); + expect(find.text('Retry'), findsOneWidget); + }); + + testWidgets('has FAB to create new todo', (tester) async { + when(() => repository.getTodos( + priorityFilter: any(named: 'priorityFilter'), + completedFilter: any(named: 'completedFilter'), + )).thenAnswer((_) async => []); + + await tester.pumpWidget(createWidgetUnderTest()); + await tester.pumpAndSettle(); + + expect(find.byType(FloatingActionButton), findsOneWidget); + }); +}