Initial commit: Flutter todo app with Riverpod
This commit is contained in:
commit
514adde721
25 changed files with 1769 additions and 0 deletions
35
.forgejo/workflows/ci.yml
Normal file
35
.forgejo/workflows/ci.yml
Normal file
|
|
@ -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
|
||||
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal file
|
|
@ -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
|
||||
27
analysis_options.yaml
Normal file
27
analysis_options.yaml
Normal file
|
|
@ -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'
|
||||
23
lib/app.dart
Normal file
23
lib/app.dart
Normal file
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
31
lib/core/routing/app_router.dart
Normal file
31
lib/core/routing/app_router.dart
Normal file
|
|
@ -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<GoRouter>((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);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
18
lib/core/theme/app_colors.dart
Normal file
18
lib/core/theme/app_colors.dart
Normal file
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
19
lib/core/theme/app_theme.dart
Normal file
19
lib/core/theme/app_theme.dart
Normal file
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
@ -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<Todo> _todos = [];
|
||||
|
||||
@override
|
||||
Future<List<Todo>> getTodos({String? priorityFilter, bool? completedFilter}) async {
|
||||
var result = List<Todo>.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<Todo> 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<Todo> 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<Todo> 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<void> deleteTodo(String id) async {
|
||||
_todos.removeWhere((t) => t.id == id);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Todo> toggleTodo(String id) async {
|
||||
final todo = await getTodo(id);
|
||||
return updateTodo(id, UpdateTodoParams(isCompleted: !todo.isCompleted));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
13
lib/features/todo/domain/entities/create_todo_params.dart
Normal file
13
lib/features/todo/domain/entities/create_todo_params.dart
Normal file
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
86
lib/features/todo/domain/entities/todo.dart
Normal file
86
lib/features/todo/domain/entities/todo.dart
Normal file
|
|
@ -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<String, dynamic> 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<String, dynamic> 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)';
|
||||
}
|
||||
17
lib/features/todo/domain/entities/update_todo_params.dart
Normal file
17
lib/features/todo/domain/entities/update_todo_params.dart
Normal file
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
13
lib/features/todo/domain/repositories/todo_repository.dart
Normal file
13
lib/features/todo/domain/repositories/todo_repository.dart
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import '../entities/todo.dart';
|
||||
import '../entities/create_todo_params.dart';
|
||||
import '../entities/update_todo_params.dart';
|
||||
|
||||
abstract class TodoRepository {
|
||||
Future<List<Todo>> getTodos({String? priorityFilter, bool? completedFilter});
|
||||
Future<Todo> getTodo(String id);
|
||||
Future<Todo> createTodo(CreateTodoParams params);
|
||||
Future<Todo> updateTodo(String id, UpdateTodoParams params);
|
||||
Future<void> deleteTodo(String id);
|
||||
Future<Todo> toggleTodo(String id);
|
||||
Future<void> reorderTodos(int oldIndex, int newIndex);
|
||||
}
|
||||
86
lib/features/todo/presentation/providers/todo_providers.dart
Normal file
86
lib/features/todo/presentation/providers/todo_providers.dart
Normal file
|
|
@ -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<TodoRepository>((ref) {
|
||||
return InMemoryTodoRepository();
|
||||
});
|
||||
|
||||
final todoListProvider =
|
||||
AsyncNotifierProvider<TodoListNotifier, List<Todo>>(
|
||||
TodoListNotifier.new,
|
||||
);
|
||||
|
||||
final todoDetailProvider =
|
||||
FutureProvider.family<Todo, String>((ref, id) {
|
||||
return ref.watch(todoRepositoryProvider).getTodo(id);
|
||||
});
|
||||
|
||||
final todoFilterProvider = StateProvider<TodoFilter>((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<List<Todo>> {
|
||||
@override
|
||||
Future<List<Todo>> build() async {
|
||||
final repository = ref.watch(todoRepositoryProvider);
|
||||
final filter = ref.watch(todoFilterProvider);
|
||||
return repository.getTodos(
|
||||
priorityFilter: filter.priority,
|
||||
completedFilter: filter.completed,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> createTodo(CreateTodoParams params) async {
|
||||
final repository = ref.read(todoRepositoryProvider);
|
||||
await repository.createTodo(params);
|
||||
ref.invalidateSelf();
|
||||
}
|
||||
|
||||
Future<void> updateTodo(String id, UpdateTodoParams params) async {
|
||||
final repository = ref.read(todoRepositoryProvider);
|
||||
await repository.updateTodo(id, params);
|
||||
ref.invalidateSelf();
|
||||
}
|
||||
|
||||
Future<void> deleteTodo(String id) async {
|
||||
final repository = ref.read(todoRepositoryProvider);
|
||||
await repository.deleteTodo(id);
|
||||
ref.invalidateSelf();
|
||||
}
|
||||
|
||||
Future<void> toggleTodo(String id) async {
|
||||
final repository = ref.read(todoRepositoryProvider);
|
||||
await repository.toggleTodo(id);
|
||||
ref.invalidateSelf();
|
||||
}
|
||||
|
||||
Future<void> 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();
|
||||
}
|
||||
}
|
||||
203
lib/features/todo/presentation/screens/todo_detail_screen.dart
Normal file
203
lib/features/todo/presentation/screens/todo_detail_screen.dart
Normal file
|
|
@ -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<TodoDetailScreen> createState() => _TodoDetailScreenState();
|
||||
}
|
||||
|
||||
class _TodoDetailScreenState extends ConsumerState<TodoDetailScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
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<void> _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<void> _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<void> _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<String>(
|
||||
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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
71
lib/features/todo/presentation/screens/todo_list_screen.dart
Normal file
71
lib/features/todo/presentation/screens/todo_list_screen.dart
Normal file
|
|
@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
43
lib/features/todo/presentation/widgets/empty_todo_state.dart
Normal file
43
lib/features/todo/presentation/widgets/empty_todo_state.dart
Normal file
|
|
@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
68
lib/features/todo/presentation/widgets/todo_filter_bar.dart
Normal file
68
lib/features/todo/presentation/widgets/todo_filter_bar.dart
Normal file
|
|
@ -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<String>(
|
||||
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')),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
78
lib/features/todo/presentation/widgets/todo_list_tile.dart
Normal file
78
lib/features/todo/presentation/widgets/todo_list_tile.dart
Normal file
|
|
@ -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}'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
12
lib/main.dart
Normal file
12
lib/main.dart
Normal file
|
|
@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
298
pubspec.lock
Normal file
298
pubspec.lock
Normal file
|
|
@ -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"
|
||||
27
pubspec.yaml
Normal file
27
pubspec.yaml
Normal file
|
|
@ -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/
|
||||
118
test/unit/todo_providers_test.dart
Normal file
118
test/unit/todo_providers_test.dart
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
183
test/unit/todo_repository_test.dart
Normal file
183
test/unit/todo_repository_test.dart
Normal file
|
|
@ -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<Exception>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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));
|
||||
});
|
||||
});
|
||||
}
|
||||
82
test/widget/todo_detail_screen_test.dart
Normal file
82
test/widget/todo_detail_screen_test.dart
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
100
test/widget/todo_list_screen_test.dart
Normal file
100
test/widget/todo_list_screen_test.dart
Normal file
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue