Initial project setup with Clean Architecture

- Flutter frontend with Provider state management
- FastAPI backend with SQLAlchemy ORM
- Internationalization support (EN/DE)
- Clean Architecture folder structure
- GoRouter for navigation
- GetIt for dependency injection
This commit is contained in:
m3mo 2026-02-02 16:43:37 +01:00
commit cb308bbf68
171 changed files with 8808 additions and 0 deletions

109
.gitignore vendored Normal file
View File

@ -0,0 +1,109 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.buildlog/
.history
.svn/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# VS Code
.vscode/
# Flutter/Dart
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
.packages
# Android
**/android/**/gradle-wrapper.jar
.gradle/
**/android/captures/
**/android/gradlew
**/android/gradlew.bat
**/android/local.properties
**/android/**/GeneratedPluginRegistrant.*
# iOS
**/ios/**/*.mode1v3
**/ios/**/*.mode2v3
**/ios/**/*.moved-aside
**/ios/**/*.pbxuser
**/ios/**/*.perspectivev3
**/ios/**/*sync/
**/ios/**/.sconsign.dblite
**/ios/**/.tags*
**/ios/**/.vagrant/
**/ios/**/DerivedData/
**/ios/**/Icon?
**/ios/**/Pods/
**/ios/**/.symlinks/
**/ios/**/profile
**/ios/**/xcuserdata
**/ios/.generated/
**/ios/Flutter/App.framework
**/ios/Flutter/Flutter.framework
**/ios/Flutter/Flutter.podspec
**/ios/Flutter/Generated.xcconfig
**/ios/Flutter/ephemeral
**/ios/Flutter/app.flx
**/ios/Flutter/app.zip
**/ios/Flutter/flutter_assets/
**/ios/Flutter/flutter_export_environment.sh
**/ios/ServiceDefinitions.json
**/ios/Runner/GeneratedPluginRegistrant.*
# macOS
**/macos/Flutter/ephemeral/
# Windows
**/windows/flutter/ephemeral/
# Linux
**/linux/flutter/ephemeral/
# Web
lib/generated_plugin_registrant.dart
# Symbolication
app.*.symbols
# Obfuscation
app.*.map.json
# Exceptions to above
!**/ios/**/default.mode1v3
!**/ios/**/default.mode2v3
!**/ios/**/default.pbxuser
!**/ios/**/default.perspectivev3
# Python Backend
backend/venv/
backend/__pycache__/
backend/**/__pycache__/
backend/*.pyc
backend/.env
backend/*.db
backend/test.db
# Coverage
coverage/
*.lcov
# Generated files
*.g.dart
*.freezed.dart
.dart_tool/

45
.metadata Normal file
View File

@ -0,0 +1,45 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "67323de285b00232883f53b84095eb72be97d35c"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 67323de285b00232883f53b84095eb72be97d35c
base_revision: 67323de285b00232883f53b84095eb72be97d35c
- platform: android
create_revision: 67323de285b00232883f53b84095eb72be97d35c
base_revision: 67323de285b00232883f53b84095eb72be97d35c
- platform: ios
create_revision: 67323de285b00232883f53b84095eb72be97d35c
base_revision: 67323de285b00232883f53b84095eb72be97d35c
- platform: linux
create_revision: 67323de285b00232883f53b84095eb72be97d35c
base_revision: 67323de285b00232883f53b84095eb72be97d35c
- platform: macos
create_revision: 67323de285b00232883f53b84095eb72be97d35c
base_revision: 67323de285b00232883f53b84095eb72be97d35c
- platform: web
create_revision: 67323de285b00232883f53b84095eb72be97d35c
base_revision: 67323de285b00232883f53b84095eb72be97d35c
- platform: windows
create_revision: 67323de285b00232883f53b84095eb72be97d35c
base_revision: 67323de285b00232883f53b84095eb72be97d35c
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

153
README.md Normal file
View File

@ -0,0 +1,153 @@
# Agenda Tasks
A calendar-based daily task management application built with Flutter and FastAPI.
## Overview
Agenda Tasks helps you manage your daily tasks with a focus on what needs to be done today, not endless lists. Select a day, see your tasks, add new ones, mark them complete, or reschedule them for tomorrow.
## Features
- **Daily Agenda View**: See all tasks for a selected day
- **Calendar Navigation**: Browse and select dates easily
- **Task Management**: Create, edit, delete, and complete tasks
- **Priority Levels**: Low, Medium, High with color coding
- **Reschedule**: Move tasks to tomorrow with one tap
- **Filtering**: View All, Active, or Completed tasks
- **Internationalization**: English and German language support
- **Dark Mode**: System default, Light, or Dark theme
## Architecture
The project follows Clean Architecture principles with a clear separation of concerns:
```
lib/
├── core/ # Shared utilities, DI, errors, logging
├── features/
│ ├── tasks/
│ │ ├── data/ # Models, DataSources, Repository implementations
│ │ ├── domain/ # Entities, Enums, Repository interfaces
│ │ └── presentation/# ViewModels, Pages, Widgets
│ └── settings/
├── routing/ # GoRouter configuration
└── l10n/ # Localization (ARB files)
```
### State Management
- **Provider** with ViewModel pattern
- Each feature has its own ViewModel (ChangeNotifier)
- Result pattern for error handling
### Routing
Using `go_router` for declarative routing:
| Route | Page |
|-------|------|
| `/` | Daily Agenda |
| `/calendar` | Calendar View |
| `/task/new?date=YYYY-MM-DD` | Create Task |
| `/task/:id/edit` | Edit Task |
| `/settings` | Settings |
## Backend
REST API built with FastAPI (Python):
```
backend/
├── app/
│ ├── main.py # FastAPI app
│ ├── routes.py # API endpoints
│ ├── models.py # SQLAlchemy models
│ ├── schemas.py # Pydantic schemas
│ ├── services.py # Business logic
│ ├── db.py # Database setup
│ └── tests/ # Pytest tests
└── requirements.txt
```
### API Endpoints
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/health` | Health check |
| GET | `/tasks?date=YYYY-MM-DD` | Get tasks by date |
| POST | `/tasks` | Create task |
| PUT | `/tasks/{id}` | Update task |
| DELETE | `/tasks/{id}` | Delete task |
| PATCH | `/tasks/{id}/toggle` | Toggle completion |
| POST | `/tasks/{id}/reschedule` | Reschedule task |
## Getting Started
### Prerequisites
- Flutter SDK 3.10+
- Python 3.11+
- Android Studio / VS Code
### Backend Setup
```bash
cd backend
# Create virtual environment
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
# Install dependencies
pip install -r requirements.txt
# Copy environment file
cp .env.example .env
# Run server
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
```
### Frontend Setup
```bash
# Install dependencies
flutter pub get
# Generate localizations
flutter gen-l10n
# Run on Android (with backend running)
flutter run
```
### Running Tests
**Backend:**
```bash
cd backend
pytest -v
```
**Frontend:**
```bash
flutter test
```
## Internationalization (i18n)
The app supports multiple languages through ARB files in `lib/l10n/`:
- `app_en.arb` - English (default)
- `app_de.arb` - German
### Adding a New Language
1. Create a new ARB file: `lib/l10n/app_XX.arb`
2. Copy content from `app_en.arb` and translate
3. Run `flutter gen-l10n`
4. Add the locale to `supportedLocales` in `SettingsViewModel`
## License
This project is part of a course at HFTM (Höhere Fachschule für Technik Mittelland).

28
analysis_options.yaml Normal file
View File

@ -0,0 +1,28 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

14
android/.gitignore vendored Normal file
View File

@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks

View File

@ -0,0 +1,44 @@
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.hftm.agenda_tasks"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.hftm.agenda_tasks"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
flutter {
source = "../.."
}

View File

@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@ -0,0 +1,45 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="agenda_tasks"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

View File

@ -0,0 +1,5 @@
package com.hftm.agenda_tasks
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

24
android/build.gradle.kts Normal file
View File

@ -0,0 +1,24 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}

View File

@ -0,0 +1,2 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true

View File

@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip

View File

@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
}
include(":app")

0
assets/images/.gitkeep Normal file
View File

2
backend/.env.example Normal file
View File

@ -0,0 +1,2 @@
DATABASE_URL=sqlite:///./tasks.db
DEBUG=true

0
backend/app/__init__.py Normal file
View File

15
backend/app/config.py Normal file
View File

@ -0,0 +1,15 @@
from pydantic_settings import BaseSettings
from functools import lru_cache
class Settings(BaseSettings):
database_url: str = "sqlite:///./tasks.db"
debug: bool = False
class Config:
env_file = ".env"
@lru_cache
def get_settings() -> Settings:
return Settings()

23
backend/app/db.py Normal file
View File

@ -0,0 +1,23 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base
from .config import get_settings
settings = get_settings()
engine = create_engine(
settings.database_url,
connect_args={"check_same_thread": False} if "sqlite" in settings.database_url else {},
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

44
backend/app/main.py Normal file
View File

@ -0,0 +1,44 @@
import logging
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .db import Base, engine
from .routes import router
from .config import get_settings
settings = get_settings()
logging.basicConfig(
level=logging.DEBUG if settings.debug else logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger(__name__)
Base.metadata.create_all(bind=engine)
app = FastAPI(
title="Agenda Tasks API",
description="REST API for the Agenda Tasks application",
version="1.0.0",
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(router)
@app.on_event("startup")
async def startup_event():
logger.info("Application starting up...")
logger.info(f"Debug mode: {settings.debug}")
@app.on_event("shutdown")
async def shutdown_event():
logger.info("Application shutting down...")

23
backend/app/models.py Normal file
View File

@ -0,0 +1,23 @@
import uuid
from datetime import datetime
from sqlalchemy import Column, String, Boolean, DateTime, Index
from .db import Base
class Task(Base):
__tablename__ = "tasks"
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
title = Column(String, nullable=False)
description = Column(String, nullable=True)
date = Column(String, nullable=False, index=True)
time = Column(String, nullable=True)
priority = Column(String, default="medium")
is_done = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
__table_args__ = (
Index("ix_tasks_date", "date"),
)

100
backend/app/routes.py Normal file
View File

@ -0,0 +1,100 @@
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from .db import get_db
from .schemas import (
TaskCreate,
TaskUpdate,
TaskResponse,
RescheduleRequest,
HealthResponse,
)
from .services import TaskService
router = APIRouter()
def get_task_service(db: Session = Depends(get_db)) -> TaskService:
return TaskService(db)
@router.get("/health", response_model=HealthResponse)
def health_check():
return HealthResponse()
@router.get("/tasks", response_model=list[TaskResponse])
def get_tasks(
date: str = Query(..., pattern=r"^\d{4}-\d{2}-\d{2}$"),
status: Optional[str] = Query(None, regex="^(all|active|done)$"),
service: TaskService = Depends(get_task_service),
):
tasks = service.get_tasks_by_date(date, status)
return tasks
@router.get("/tasks/{task_id}", response_model=TaskResponse)
def get_task(
task_id: str,
service: TaskService = Depends(get_task_service),
):
task = service.get_task_by_id(task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
return task
@router.post("/tasks", response_model=TaskResponse, status_code=201)
def create_task(
task_data: TaskCreate,
service: TaskService = Depends(get_task_service),
):
task = service.create_task(task_data)
return task
@router.put("/tasks/{task_id}", response_model=TaskResponse)
def update_task(
task_id: str,
task_data: TaskUpdate,
service: TaskService = Depends(get_task_service),
):
task = service.update_task(task_id, task_data)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
return task
@router.delete("/tasks/{task_id}", status_code=204)
def delete_task(
task_id: str,
service: TaskService = Depends(get_task_service),
):
deleted = service.delete_task(task_id)
if not deleted:
raise HTTPException(status_code=404, detail="Task not found")
return None
@router.patch("/tasks/{task_id}/toggle", response_model=TaskResponse)
def toggle_task_status(
task_id: str,
service: TaskService = Depends(get_task_service),
):
task = service.toggle_task_status(task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
return task
@router.post("/tasks/{task_id}/reschedule", response_model=TaskResponse)
def reschedule_task(
task_id: str,
request: RescheduleRequest,
service: TaskService = Depends(get_task_service),
):
task = service.reschedule_task(task_id, request.target_date)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
return task

49
backend/app/schemas.py Normal file
View File

@ -0,0 +1,49 @@
from datetime import datetime
from enum import Enum
from typing import Optional
from pydantic import BaseModel, Field
class Priority(str, Enum):
low = "low"
medium = "medium"
high = "high"
class TaskBase(BaseModel):
title: str = Field(..., min_length=1, max_length=200)
description: Optional[str] = None
date: str = Field(..., pattern=r"^\d{4}-\d{2}-\d{2}$")
time: Optional[str] = Field(None, pattern=r"^\d{2}:\d{2}$")
priority: Priority = Priority.medium
class TaskCreate(TaskBase):
pass
class TaskUpdate(BaseModel):
title: Optional[str] = Field(None, min_length=1, max_length=200)
description: Optional[str] = None
date: Optional[str] = Field(None, pattern=r"^\d{4}-\d{2}-\d{2}$")
time: Optional[str] = Field(None, pattern=r"^\d{2}:\d{2}$")
priority: Optional[Priority] = None
is_done: Optional[bool] = None
class TaskResponse(TaskBase):
id: str
is_done: bool
created_at: Optional[datetime]
updated_at: Optional[datetime]
class Config:
from_attributes = True
class RescheduleRequest(BaseModel):
target_date: str = Field(..., pattern=r"^\d{4}-\d{2}-\d{2}$")
class HealthResponse(BaseModel):
status: str = "ok"

83
backend/app/services.py Normal file
View File

@ -0,0 +1,83 @@
from typing import Optional
from sqlalchemy.orm import Session
from .models import Task
from .schemas import TaskCreate, TaskUpdate
class TaskService:
def __init__(self, db: Session):
self.db = db
def get_tasks_by_date(
self, date: str, status: Optional[str] = None
) -> list[Task]:
query = self.db.query(Task).filter(Task.date == date)
if status == "active":
query = query.filter(Task.is_done == False)
elif status == "done":
query = query.filter(Task.is_done == True)
return query.order_by(Task.created_at.desc()).all()
def get_task_by_id(self, task_id: str) -> Optional[Task]:
return self.db.query(Task).filter(Task.id == task_id).first()
def create_task(self, task_data: TaskCreate) -> Task:
task = Task(
title=task_data.title,
description=task_data.description,
date=task_data.date,
time=task_data.time,
priority=task_data.priority.value,
)
self.db.add(task)
self.db.commit()
self.db.refresh(task)
return task
def update_task(self, task_id: str, task_data: TaskUpdate) -> Optional[Task]:
task = self.get_task_by_id(task_id)
if not task:
return None
update_data = task_data.model_dump(exclude_unset=True)
if "priority" in update_data and update_data["priority"]:
update_data["priority"] = update_data["priority"].value
for field, value in update_data.items():
setattr(task, field, value)
self.db.commit()
self.db.refresh(task)
return task
def delete_task(self, task_id: str) -> bool:
task = self.get_task_by_id(task_id)
if not task:
return False
self.db.delete(task)
self.db.commit()
return True
def toggle_task_status(self, task_id: str) -> Optional[Task]:
task = self.get_task_by_id(task_id)
if not task:
return None
task.is_done = not task.is_done
self.db.commit()
self.db.refresh(task)
return task
def reschedule_task(self, task_id: str, target_date: str) -> Optional[Task]:
task = self.get_task_by_id(task_id)
if not task:
return None
task.date = target_date
self.db.commit()
self.db.refresh(task)
return task

View File

View File

@ -0,0 +1,162 @@
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from ..main import app
from ..db import Base, get_db
SQLALCHEMY_DATABASE_URL = "sqlite:///./test.db"
engine = create_engine(
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
)
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def override_get_db():
db = TestingSessionLocal()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_get_db
@pytest.fixture(autouse=True)
def setup_database():
Base.metadata.create_all(bind=engine)
yield
Base.metadata.drop_all(bind=engine)
client = TestClient(app)
class TestHealthEndpoint:
def test_health_check(self):
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
class TestTasksEndpoint:
def test_create_task(self):
task_data = {
"title": "Test Task",
"description": "Test Description",
"date": "2026-02-02",
"priority": "medium",
}
response = client.post("/tasks", json=task_data)
assert response.status_code == 201
data = response.json()
assert data["title"] == "Test Task"
assert data["is_done"] == False
assert "id" in data
def test_create_task_validation_error(self):
task_data = {
"title": "",
"date": "2026-02-02",
}
response = client.post("/tasks", json=task_data)
assert response.status_code == 422
def test_get_tasks_by_date(self):
task_data = {
"title": "Test Task",
"date": "2026-02-02",
"priority": "high",
}
client.post("/tasks", json=task_data)
response = client.get("/tasks?date=2026-02-02")
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert data[0]["title"] == "Test Task"
def test_get_tasks_filter_active(self):
client.post(
"/tasks",
json={"title": "Active Task", "date": "2026-02-02", "priority": "low"},
)
create_response = client.post(
"/tasks",
json={"title": "Done Task", "date": "2026-02-02", "priority": "low"},
)
task_id = create_response.json()["id"]
client.patch(f"/tasks/{task_id}/toggle")
response = client.get("/tasks?date=2026-02-02&status=active")
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert data[0]["title"] == "Active Task"
def test_get_task_not_found(self):
response = client.get("/tasks/nonexistent-id")
assert response.status_code == 404
def test_update_task(self):
create_response = client.post(
"/tasks",
json={"title": "Original", "date": "2026-02-02", "priority": "low"},
)
task_id = create_response.json()["id"]
response = client.put(
f"/tasks/{task_id}",
json={"title": "Updated"},
)
assert response.status_code == 200
assert response.json()["title"] == "Updated"
def test_delete_task(self):
create_response = client.post(
"/tasks",
json={"title": "To Delete", "date": "2026-02-02", "priority": "low"},
)
task_id = create_response.json()["id"]
response = client.delete(f"/tasks/{task_id}")
assert response.status_code == 204
get_response = client.get(f"/tasks/{task_id}")
assert get_response.status_code == 404
def test_delete_task_not_found(self):
response = client.delete("/tasks/nonexistent-id")
assert response.status_code == 404
def test_toggle_task_status(self):
create_response = client.post(
"/tasks",
json={"title": "Toggle Me", "date": "2026-02-02", "priority": "medium"},
)
task_id = create_response.json()["id"]
assert create_response.json()["is_done"] == False
response = client.patch(f"/tasks/{task_id}/toggle")
assert response.status_code == 200
assert response.json()["is_done"] == True
response = client.patch(f"/tasks/{task_id}/toggle")
assert response.json()["is_done"] == False
def test_reschedule_task(self):
create_response = client.post(
"/tasks",
json={"title": "Reschedule Me", "date": "2026-02-02", "priority": "high"},
)
task_id = create_response.json()["id"]
response = client.post(
f"/tasks/{task_id}/reschedule",
json={"target_date": "2026-02-03"},
)
assert response.status_code == 200
assert response.json()["date"] == "2026-02-03"

8
backend/requirements.txt Normal file
View File

@ -0,0 +1,8 @@
fastapi==0.115.0
uvicorn[standard]==0.32.0
sqlalchemy==2.0.36
pydantic==2.10.0
pydantic-settings==2.6.0
python-dotenv==1.0.1
pytest==8.3.0
httpx==0.28.0

34
ios/.gitignore vendored Normal file
View File

@ -0,0 +1,34 @@
**/dgph
*.mode1v3
*.mode2v3
*.moved-aside
*.pbxuser
*.perspectivev3
**/*sync/
.sconsign.dblite
.tags*
**/.vagrant/
**/DerivedData/
Icon?
**/Pods/
**/.symlinks/
profile
xcuserdata
**/.generated/
Flutter/App.framework
Flutter/Flutter.framework
Flutter/Flutter.podspec
Flutter/Generated.xcconfig
Flutter/ephemeral/
Flutter/app.flx
Flutter/app.zip
Flutter/flutter_assets/
Flutter/flutter_export_environment.sh
ServiceDefinitions.json
Runner/GeneratedPluginRegistrant.*
# Exceptions to above rules.
!default.mode1v3
!default.mode2v3
!default.pbxuser
!default.perspectivev3

View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>App</string>
<key>CFBundleIdentifier</key>
<string>io.flutter.flutter.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>App</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>13.0</string>
</dict>
</plist>

View File

@ -0,0 +1 @@
#include "Generated.xcconfig"

View File

@ -0,0 +1 @@
#include "Generated.xcconfig"

View File

@ -0,0 +1,616 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
remoteInfo = Runner;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
331C8082294A63A400263BE5 /* RunnerTests */ = {
isa = PBXGroup;
children = (
331C807B294A618700263BE5 /* RunnerTests.swift */,
);
path = RunnerTests;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
9740EEB31CF90195004384FC /* Generated.xcconfig */,
);
name = Flutter;
sourceTree = "<group>";
};
97C146E51CF9000F007C117D = {
isa = PBXGroup;
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
);
sourceTree = "<group>";
};
97C146EF1CF9000F007C117D /* Products */ = {
isa = PBXGroup;
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
97C146FA1CF9000F007C117D /* Main.storyboard */,
97C146FD1CF9000F007C117D /* Assets.xcassets */,
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
97C147021CF9000F007C117D /* Info.plist */,
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
path = Runner;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
331C8080294A63A400263BE5 /* RunnerTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
331C807D294A63A400263BE5 /* Sources */,
331C807F294A63A400263BE5 /* Resources */,
);
buildRules = (
);
dependencies = (
331C8086294A63A400263BE5 /* PBXTargetDependency */,
);
name = RunnerTests;
productName = RunnerTests;
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
97C146ED1CF9000F007C117D /* Runner */ = {
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
);
buildRules = (
);
dependencies = (
);
name = Runner;
productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
331C8080294A63A400263BE5 = {
CreatedOnToolsVersion = 14.0;
TestTargetID = 97C146ED1CF9000F007C117D;
};
97C146ED1CF9000F007C117D = {
CreatedOnToolsVersion = 7.3.1;
LastSwiftMigration = 1100;
};
};
};
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
compatibilityVersion = "Xcode 9.3";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 97C146E51CF9000F007C117D;
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
97C146ED1CF9000F007C117D /* Runner */,
331C8080294A63A400263BE5 /* RunnerTests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
331C807F294A63A400263BE5 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EC1CF9000F007C117D /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
);
name = "Thin Binary";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Run Script";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
331C807D294A63A400263BE5 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EA1CF9000F007C117D /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 97C146ED1CF9000F007C117D /* Runner */;
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C146FB1CF9000F007C117D /* Base */,
);
name = Main.storyboard;
sourceTree = "<group>";
};
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C147001CF9000F007C117D /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
249021D3217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Profile;
};
249021D4217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.hftm.agendaTasks;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Profile;
};
331C8088294A63A400263BE5 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.hftm.agendaTasks.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Debug;
};
331C8089294A63A400263BE5 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.hftm.agendaTasks.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Release;
};
331C808A294A63A400263BE5 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.hftm.agendaTasks.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Profile;
};
97C147031CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
97C147041CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
97C147061CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.hftm.agendaTasks;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
};
97C147071CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.hftm.agendaTasks;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
331C8088294A63A400263BE5 /* Debug */,
331C8089294A63A400263BE5 /* Release */,
331C808A294A63A400263BE5 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147031CF9000F007C117D /* Debug */,
97C147041CF9000F007C117D /* Release */,
249021D3217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147061CF9000F007C117D /* Debug */,
97C147071CF9000F007C117D /* Release */,
249021D4217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View File

@ -0,0 +1,101 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "331C8080294A63A400263BE5"
BuildableName = "RunnerTests.xctest"
BlueprintName = "RunnerTests"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
</Workspace>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View File

@ -0,0 +1,13 @@
import Flutter
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}

View File

@ -0,0 +1,122 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,23 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "LaunchImage.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@2x.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@3x.png",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

View File

@ -0,0 +1,5 @@
# Launch Screen Assets
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.

View File

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
</resources>
</document>

View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
</dependencies>
<scenes>
<!--Flutter View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>

49
ios/Runner/Info.plist Normal file
View File

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Agenda Tasks</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>agenda_tasks</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
</dict>
</plist>

View File

@ -0,0 +1 @@
#import "GeneratedPluginRegistrant.h"

View File

@ -0,0 +1,12 @@
import Flutter
import UIKit
import XCTest
class RunnerTests: XCTestCase {
func testExample() {
// If you add code to the Runner application, consider adding tests here.
// See https://developer.apple.com/documentation/xctest for more information about using XCTest.
}
}

3
l10n.yaml Normal file
View File

@ -0,0 +1,3 @@
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart

57
lib/app.dart Normal file
View File

@ -0,0 +1,57 @@
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:provider/provider.dart';
import 'core/di/injection_container.dart';
import 'features/settings/presentation/viewmodels/settings_viewmodel.dart';
import 'routing/app_router.dart';
class AgendaApp extends StatelessWidget {
const AgendaApp({super.key});
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => getIt<SettingsViewModel>()),
],
child: Consumer<SettingsViewModel>(
builder: (context, settingsVm, _) {
return MaterialApp.router(
title: 'Agenda Tasks',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF1A365D),
brightness: Brightness.light,
),
useMaterial3: true,
appBarTheme: const AppBarTheme(
centerTitle: true,
elevation: 0,
),
),
darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF1A365D),
brightness: Brightness.dark,
),
useMaterial3: true,
),
themeMode: settingsVm.themeMode,
locale: settingsVm.locale,
supportedLocales: AppLocalizations.supportedLocales,
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
routerConfig: AppRouter.router,
);
},
),
);
}
}

View File

@ -0,0 +1,46 @@
import 'package:get_it/get_it.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../features/settings/presentation/viewmodels/settings_viewmodel.dart';
import '../../features/tasks/presentation/viewmodels/daily_tasks_viewmodel.dart';
import '../../features/tasks/presentation/viewmodels/task_form_viewmodel.dart';
import '../../features/tasks/domain/repositories/task_repository.dart';
import '../../features/tasks/data/repositories/task_repository_impl.dart';
import '../../features/tasks/data/datasources/task_remote_datasource.dart';
import '../../features/settings/data/settings_local_datasource.dart';
import '../logging/app_logger.dart';
final getIt = GetIt.instance;
Future<void> init() async {
// External
final sharedPreferences = await SharedPreferences.getInstance();
getIt.registerSingleton<SharedPreferences>(sharedPreferences);
// Logger
getIt.registerSingleton<AppLogger>(AppLogger());
// Data sources
getIt.registerLazySingleton<TaskRemoteDataSource>(
() => TaskRemoteDataSourceImpl(logger: getIt()),
);
getIt.registerLazySingleton<SettingsLocalDataSource>(
() => SettingsLocalDataSourceImpl(sharedPreferences: getIt()),
);
// Repositories
getIt.registerLazySingleton<TaskRepository>(
() => TaskRepositoryImpl(remoteDataSource: getIt(), logger: getIt()),
);
// ViewModels
getIt.registerFactory<DailyTasksViewModel>(
() => DailyTasksViewModel(repository: getIt(), logger: getIt()),
);
getIt.registerFactory<TaskFormViewModel>(
() => TaskFormViewModel(repository: getIt(), logger: getIt()),
);
getIt.registerLazySingleton<SettingsViewModel>(
() => SettingsViewModel(dataSource: getIt(), logger: getIt()),
);
}

View File

@ -0,0 +1,37 @@
class ServerException implements Exception {
final String message;
final int? statusCode;
const ServerException({required this.message, this.statusCode});
@override
String toString() => 'ServerException: $message (status: $statusCode)';
}
class NetworkException implements Exception {
final String message;
const NetworkException({this.message = 'Network connection failed'});
@override
String toString() => 'NetworkException: $message';
}
class ValidationException implements Exception {
final String message;
final Map<String, dynamic>? errors;
const ValidationException({required this.message, this.errors});
@override
String toString() => 'ValidationException: $message';
}
class NotFoundException implements Exception {
final String message;
const NotFoundException({this.message = 'Resource not found'});
@override
String toString() => 'NotFoundException: $message';
}

View File

@ -0,0 +1,29 @@
abstract class Failure {
final String message;
final String? code;
const Failure({required this.message, this.code});
@override
String toString() => 'Failure(message: $message, code: $code)';
}
class ServerFailure extends Failure {
const ServerFailure({required super.message, super.code});
}
class NetworkFailure extends Failure {
const NetworkFailure({super.message = 'Network connection error', super.code});
}
class ValidationFailure extends Failure {
const ValidationFailure({required super.message, super.code});
}
class NotFoundFailure extends Failure {
const NotFoundFailure({super.message = 'Resource not found', super.code});
}
class UnexpectedFailure extends Failure {
const UnexpectedFailure({super.message = 'An unexpected error occurred', super.code});
}

View File

@ -0,0 +1,36 @@
import 'failures.dart';
sealed class Result<T> {
const Result();
bool get isSuccess => this is Success<T>;
bool get isFailure => this is Failure;
T? get data => this is Success<T> ? (this as Success<T>).data : null;
Failure? get failure => this is Error<T> ? (this as Error<T>).failure : null;
R when<R>({
required R Function(T data) success,
required R Function(Failure failure) error,
}) {
if (this is Success<T>) {
return success((this as Success<T>).data);
} else {
return error((this as Error<T>).failure);
}
}
}
class Success<T> extends Result<T> {
@override
final T data;
const Success(this.data);
}
class Error<T> extends Result<T> {
@override
final Failure failure;
const Error(this.failure);
}

View File

@ -0,0 +1,37 @@
import 'package:logger/logger.dart';
class AppLogger {
final Logger _logger;
AppLogger()
: _logger = Logger(
printer: PrettyPrinter(
methodCount: 0,
errorMethodCount: 5,
lineLength: 80,
colors: true,
printEmojis: true,
dateTimeFormat: DateTimeFormat.onlyTimeAndSinceStart,
),
);
void debug(String message, [dynamic error, StackTrace? stackTrace]) {
_logger.d(message, error: error, stackTrace: stackTrace);
}
void info(String message, [dynamic error, StackTrace? stackTrace]) {
_logger.i(message, error: error, stackTrace: stackTrace);
}
void warning(String message, [dynamic error, StackTrace? stackTrace]) {
_logger.w(message, error: error, stackTrace: stackTrace);
}
void error(String message, [dynamic error, StackTrace? stackTrace]) {
_logger.e(message, error: error, stackTrace: stackTrace);
}
void verbose(String message, [dynamic error, StackTrace? stackTrace]) {
_logger.t(message, error: error, stackTrace: stackTrace);
}
}

View File

@ -0,0 +1,42 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
abstract class SettingsLocalDataSource {
Locale? getLocale();
Future<void> setLocale(Locale locale);
ThemeMode getThemeMode();
Future<void> setThemeMode(ThemeMode mode);
}
class SettingsLocalDataSourceImpl implements SettingsLocalDataSource {
final SharedPreferences sharedPreferences;
static const String _localeKey = 'locale';
static const String _themeModeKey = 'theme_mode';
SettingsLocalDataSourceImpl({required this.sharedPreferences});
@override
Locale? getLocale() {
final localeCode = sharedPreferences.getString(_localeKey);
if (localeCode == null) return null;
return Locale(localeCode);
}
@override
Future<void> setLocale(Locale locale) async {
await sharedPreferences.setString(_localeKey, locale.languageCode);
}
@override
ThemeMode getThemeMode() {
final index = sharedPreferences.getInt(_themeModeKey);
if (index == null) return ThemeMode.system;
return ThemeMode.values[index];
}
@override
Future<void> setThemeMode(ThemeMode mode) async {
await sharedPreferences.setInt(_themeModeKey, mode.index);
}
}

View File

@ -0,0 +1,181 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import '../viewmodels/settings_viewmodel.dart';
class SettingsPage extends StatelessWidget {
const SettingsPage({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final settingsVm = context.watch<SettingsViewModel>();
return Scaffold(
appBar: AppBar(
title: Text(l10n.settings),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.pop(),
),
),
body: ListView(
children: [
_SectionHeader(title: l10n.general),
ListTile(
leading: const Icon(Icons.language),
title: Text(l10n.language),
subtitle: Text(
settingsVm.locale != null
? settingsVm.getLanguageName(settingsVm.locale!)
: l10n.systemDefault,
),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showLanguageDialog(context, settingsVm, l10n),
),
const Divider(),
_SectionHeader(title: l10n.appearance),
ListTile(
leading: const Icon(Icons.dark_mode),
title: Text(l10n.darkMode),
subtitle: Text(_getThemeModeLabel(settingsVm.themeMode, l10n)),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showThemeDialog(context, settingsVm, l10n),
),
const Divider(),
_SectionHeader(title: l10n.about),
ListTile(
leading: const Icon(Icons.info_outline),
title: Text(l10n.version),
subtitle: const Text('1.0.0'),
),
],
),
);
}
String _getThemeModeLabel(ThemeMode mode, AppLocalizations l10n) {
switch (mode) {
case ThemeMode.system:
return l10n.systemDefault;
case ThemeMode.light:
return l10n.lightMode;
case ThemeMode.dark:
return l10n.darkModeOption;
}
}
void _showLanguageDialog(
BuildContext context,
SettingsViewModel vm,
AppLocalizations l10n,
) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(l10n.language),
content: Column(
mainAxisSize: MainAxisSize.min,
children: SettingsViewModel.supportedLocales.map((locale) {
return RadioListTile<Locale>(
title: Text(vm.getLanguageName(locale)),
value: locale,
groupValue: vm.locale,
onChanged: (value) {
if (value != null) {
vm.setLocale(value);
Navigator.pop(context);
}
},
);
}).toList(),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l10n.cancel),
),
],
),
);
}
void _showThemeDialog(
BuildContext context,
SettingsViewModel vm,
AppLocalizations l10n,
) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(l10n.darkMode),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
RadioListTile<ThemeMode>(
title: Text(l10n.systemDefault),
value: ThemeMode.system,
groupValue: vm.themeMode,
onChanged: (value) {
if (value != null) {
vm.setThemeMode(value);
Navigator.pop(context);
}
},
),
RadioListTile<ThemeMode>(
title: Text(l10n.lightMode),
value: ThemeMode.light,
groupValue: vm.themeMode,
onChanged: (value) {
if (value != null) {
vm.setThemeMode(value);
Navigator.pop(context);
}
},
),
RadioListTile<ThemeMode>(
title: Text(l10n.darkModeOption),
value: ThemeMode.dark,
groupValue: vm.themeMode,
onChanged: (value) {
if (value != null) {
vm.setThemeMode(value);
Navigator.pop(context);
}
},
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l10n.cancel),
),
],
),
);
}
}
class _SectionHeader extends StatelessWidget {
final String title;
const _SectionHeader({required this.title});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
title.toUpperCase(),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
);
}
}

View File

@ -0,0 +1,58 @@
import 'package:flutter/material.dart';
import '../../../../core/logging/app_logger.dart';
import '../../data/settings_local_datasource.dart';
class SettingsViewModel extends ChangeNotifier {
final SettingsLocalDataSource dataSource;
final AppLogger logger;
SettingsViewModel({
required this.dataSource,
required this.logger,
}) {
_loadSettings();
}
Locale? _locale;
ThemeMode _themeMode = ThemeMode.system;
Locale? get locale => _locale;
ThemeMode get themeMode => _themeMode;
static const supportedLocales = [
Locale('en'),
Locale('de'),
];
void _loadSettings() {
_locale = dataSource.getLocale();
_themeMode = dataSource.getThemeMode();
logger.info('Settings loaded: locale=$_locale, themeMode=$_themeMode');
}
Future<void> setLocale(Locale locale) async {
_locale = locale;
await dataSource.setLocale(locale);
logger.info('Locale changed to: ${locale.languageCode}');
notifyListeners();
}
Future<void> setThemeMode(ThemeMode mode) async {
_themeMode = mode;
await dataSource.setThemeMode(mode);
logger.info('Theme mode changed to: $mode');
notifyListeners();
}
String getLanguageName(Locale locale) {
switch (locale.languageCode) {
case 'en':
return 'English';
case 'de':
return 'Deutsch';
default:
return locale.languageCode.toUpperCase();
}
}
}

View File

@ -0,0 +1,217 @@
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import '../../../../core/errors/exceptions.dart';
import '../../../../core/logging/app_logger.dart';
import '../models/task_model.dart';
abstract class TaskRemoteDataSource {
Future<List<TaskModel>> getTasksByDate(String date, {String? status});
Future<TaskModel> getTaskById(String id);
Future<TaskModel> createTask(TaskModel task);
Future<TaskModel> updateTask(TaskModel task);
Future<void> deleteTask(String id);
Future<TaskModel> toggleTaskStatus(String id);
Future<TaskModel> rescheduleTask(String id, String targetDate);
}
class TaskRemoteDataSourceImpl implements TaskRemoteDataSource {
final AppLogger logger;
final http.Client _client;
static const String _baseUrl = 'http://10.0.2.2:8000';
TaskRemoteDataSourceImpl({
required this.logger,
http.Client? client,
}) : _client = client ?? http.Client();
Map<String, String> get _headers => {
'Content-Type': 'application/json',
'Accept': 'application/json',
};
@override
Future<List<TaskModel>> getTasksByDate(String date, {String? status}) async {
final queryParams = {'date': date};
if (status != null && status != 'all') {
queryParams['status'] = status;
}
final uri = Uri.parse('$_baseUrl/tasks').replace(queryParameters: queryParams);
logger.info('GET $uri');
try {
final response = await _client.get(uri, headers: _headers);
logger.debug('Response: ${response.statusCode}');
if (response.statusCode == 200) {
final List<dynamic> jsonList = json.decode(response.body);
return jsonList.map((json) => TaskModel.fromJson(json)).toList();
} else {
throw ServerException(
message: 'Failed to load tasks',
statusCode: response.statusCode,
);
}
} on SocketException {
throw const NetworkException();
}
}
@override
Future<TaskModel> getTaskById(String id) async {
final uri = Uri.parse('$_baseUrl/tasks/$id');
logger.info('GET $uri');
try {
final response = await _client.get(uri, headers: _headers);
if (response.statusCode == 200) {
return TaskModel.fromJson(json.decode(response.body));
} else if (response.statusCode == 404) {
throw const NotFoundException(message: 'Task not found');
} else {
throw ServerException(
message: 'Failed to load task',
statusCode: response.statusCode,
);
}
} on SocketException {
throw const NetworkException();
}
}
@override
Future<TaskModel> createTask(TaskModel task) async {
final uri = Uri.parse('$_baseUrl/tasks');
logger.info('POST $uri');
try {
final response = await _client.post(
uri,
headers: _headers,
body: json.encode(task.toCreateJson()),
);
logger.debug('Response: ${response.statusCode}');
if (response.statusCode == 200 || response.statusCode == 201) {
return TaskModel.fromJson(json.decode(response.body));
} else if (response.statusCode == 400) {
throw ValidationException(
message: 'Invalid task data',
errors: json.decode(response.body),
);
} else {
throw ServerException(
message: 'Failed to create task',
statusCode: response.statusCode,
);
}
} on SocketException {
throw const NetworkException();
}
}
@override
Future<TaskModel> updateTask(TaskModel task) async {
final uri = Uri.parse('$_baseUrl/tasks/${task.id}');
logger.info('PUT $uri');
try {
final response = await _client.put(
uri,
headers: _headers,
body: json.encode(task.toJson()),
);
if (response.statusCode == 200) {
return TaskModel.fromJson(json.decode(response.body));
} else if (response.statusCode == 404) {
throw const NotFoundException(message: 'Task not found');
} else {
throw ServerException(
message: 'Failed to update task',
statusCode: response.statusCode,
);
}
} on SocketException {
throw const NetworkException();
}
}
@override
Future<void> deleteTask(String id) async {
final uri = Uri.parse('$_baseUrl/tasks/$id');
logger.info('DELETE $uri');
try {
final response = await _client.delete(uri, headers: _headers);
if (response.statusCode == 204 || response.statusCode == 200) {
return;
} else if (response.statusCode == 404) {
throw const NotFoundException(message: 'Task not found');
} else {
throw ServerException(
message: 'Failed to delete task',
statusCode: response.statusCode,
);
}
} on SocketException {
throw const NetworkException();
}
}
@override
Future<TaskModel> toggleTaskStatus(String id) async {
final uri = Uri.parse('$_baseUrl/tasks/$id/toggle');
logger.info('PATCH $uri');
try {
final response = await _client.patch(uri, headers: _headers);
if (response.statusCode == 200) {
return TaskModel.fromJson(json.decode(response.body));
} else if (response.statusCode == 404) {
throw const NotFoundException(message: 'Task not found');
} else {
throw ServerException(
message: 'Failed to toggle task status',
statusCode: response.statusCode,
);
}
} on SocketException {
throw const NetworkException();
}
}
@override
Future<TaskModel> rescheduleTask(String id, String targetDate) async {
final uri = Uri.parse('$_baseUrl/tasks/$id/reschedule');
logger.info('POST $uri');
try {
final response = await _client.post(
uri,
headers: _headers,
body: json.encode({'target_date': targetDate}),
);
if (response.statusCode == 200) {
return TaskModel.fromJson(json.decode(response.body));
} else if (response.statusCode == 404) {
throw const NotFoundException(message: 'Task not found');
} else {
throw ServerException(
message: 'Failed to reschedule task',
statusCode: response.statusCode,
);
}
} on SocketException {
throw const NetworkException();
}
}
}

View File

@ -0,0 +1,74 @@
import '../../domain/entities/task_entity.dart';
import '../../domain/enums/priority.dart';
class TaskModel extends TaskEntity {
const TaskModel({
required super.id,
required super.title,
super.description,
required super.date,
super.time,
super.priority,
super.isDone,
super.createdAt,
super.updatedAt,
});
factory TaskModel.fromJson(Map<String, dynamic> json) {
return TaskModel(
id: json['id'] as String,
title: json['title'] as String,
description: json['description'] as String?,
date: DateTime.parse(json['date'] as String),
time: json['time'] as String?,
priority: Priority.fromString(json['priority'] as String? ?? 'medium'),
isDone: json['is_done'] as bool? ?? false,
createdAt: json['created_at'] != null
? DateTime.parse(json['created_at'] as String)
: null,
updatedAt: json['updated_at'] != null
? DateTime.parse(json['updated_at'] as String)
: null,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'title': title,
'description': description,
'date': _formatDate(date),
'time': time,
'priority': priority.name,
'is_done': isDone,
};
}
Map<String, dynamic> toCreateJson() {
return {
'title': title,
'description': description,
'date': _formatDate(date),
'time': time,
'priority': priority.name,
};
}
String _formatDate(DateTime date) {
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
}
factory TaskModel.fromEntity(TaskEntity entity) {
return TaskModel(
id: entity.id,
title: entity.title,
description: entity.description,
date: entity.date,
time: entity.time,
priority: entity.priority,
isDone: entity.isDone,
createdAt: entity.createdAt,
updatedAt: entity.updatedAt,
);
}
}

View File

@ -0,0 +1,156 @@
import '../../../../core/errors/exceptions.dart';
import '../../../../core/errors/failures.dart';
import '../../../../core/errors/result.dart';
import '../../../../core/logging/app_logger.dart';
import '../../domain/entities/task_entity.dart';
import '../../domain/repositories/task_repository.dart';
import '../datasources/task_remote_datasource.dart';
import '../models/task_model.dart';
class TaskRepositoryImpl implements TaskRepository {
final TaskRemoteDataSource remoteDataSource;
final AppLogger logger;
TaskRepositoryImpl({
required this.remoteDataSource,
required this.logger,
});
String _formatDate(DateTime date) {
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
}
@override
Future<Result<List<TaskEntity>>> getTasksByDate(DateTime date, {String? status}) async {
try {
final tasks = await remoteDataSource.getTasksByDate(
_formatDate(date),
status: status,
);
logger.info('Loaded ${tasks.length} tasks for ${_formatDate(date)}');
return Success(tasks);
} on NetworkException catch (e) {
logger.error('Network error: ${e.message}');
return Error(NetworkFailure(message: e.message));
} on ServerException catch (e) {
logger.error('Server error: ${e.message}');
return Error(ServerFailure(message: e.message, code: e.statusCode?.toString()));
} catch (e, stackTrace) {
logger.error('Unexpected error', e, stackTrace);
return Error(UnexpectedFailure(message: e.toString()));
}
}
@override
Future<Result<TaskEntity>> getTaskById(String id) async {
try {
final task = await remoteDataSource.getTaskById(id);
return Success(task);
} on NotFoundException catch (e) {
logger.warning('Task not found: $id');
return Error(NotFoundFailure(message: e.message));
} on NetworkException catch (e) {
return Error(NetworkFailure(message: e.message));
} on ServerException catch (e) {
return Error(ServerFailure(message: e.message));
} catch (e, stackTrace) {
logger.error('Unexpected error', e, stackTrace);
return Error(UnexpectedFailure(message: e.toString()));
}
}
@override
Future<Result<TaskEntity>> createTask(TaskEntity task) async {
try {
final model = TaskModel.fromEntity(task);
final created = await remoteDataSource.createTask(model);
logger.info('Created task: ${created.id}');
return Success(created);
} on ValidationException catch (e) {
logger.warning('Validation error: ${e.message}');
return Error(ValidationFailure(message: e.message));
} on NetworkException catch (e) {
return Error(NetworkFailure(message: e.message));
} on ServerException catch (e) {
return Error(ServerFailure(message: e.message));
} catch (e, stackTrace) {
logger.error('Unexpected error', e, stackTrace);
return Error(UnexpectedFailure(message: e.toString()));
}
}
@override
Future<Result<TaskEntity>> updateTask(TaskEntity task) async {
try {
final model = TaskModel.fromEntity(task);
final updated = await remoteDataSource.updateTask(model);
logger.info('Updated task: ${updated.id}');
return Success(updated);
} on NotFoundException catch (e) {
return Error(NotFoundFailure(message: e.message));
} on ValidationException catch (e) {
return Error(ValidationFailure(message: e.message));
} on NetworkException catch (e) {
return Error(NetworkFailure(message: e.message));
} on ServerException catch (e) {
return Error(ServerFailure(message: e.message));
} catch (e, stackTrace) {
logger.error('Unexpected error', e, stackTrace);
return Error(UnexpectedFailure(message: e.toString()));
}
}
@override
Future<Result<void>> deleteTask(String id) async {
try {
await remoteDataSource.deleteTask(id);
logger.info('Deleted task: $id');
return const Success(null);
} on NotFoundException catch (e) {
return Error(NotFoundFailure(message: e.message));
} on NetworkException catch (e) {
return Error(NetworkFailure(message: e.message));
} on ServerException catch (e) {
return Error(ServerFailure(message: e.message));
} catch (e, stackTrace) {
logger.error('Unexpected error', e, stackTrace);
return Error(UnexpectedFailure(message: e.toString()));
}
}
@override
Future<Result<TaskEntity>> toggleTaskStatus(String id) async {
try {
final task = await remoteDataSource.toggleTaskStatus(id);
logger.info('Toggled task status: $id -> ${task.isDone}');
return Success(task);
} on NotFoundException catch (e) {
return Error(NotFoundFailure(message: e.message));
} on NetworkException catch (e) {
return Error(NetworkFailure(message: e.message));
} on ServerException catch (e) {
return Error(ServerFailure(message: e.message));
} catch (e, stackTrace) {
logger.error('Unexpected error', e, stackTrace);
return Error(UnexpectedFailure(message: e.toString()));
}
}
@override
Future<Result<TaskEntity>> rescheduleTask(String id, DateTime targetDate) async {
try {
final task = await remoteDataSource.rescheduleTask(id, _formatDate(targetDate));
logger.info('Rescheduled task: $id -> ${_formatDate(targetDate)}');
return Success(task);
} on NotFoundException catch (e) {
return Error(NotFoundFailure(message: e.message));
} on NetworkException catch (e) {
return Error(NetworkFailure(message: e.message));
} on ServerException catch (e) {
return Error(ServerFailure(message: e.message));
} catch (e, stackTrace) {
logger.error('Unexpected error', e, stackTrace);
return Error(UnexpectedFailure(message: e.toString()));
}
}
}

View File

@ -0,0 +1,58 @@
import '../enums/priority.dart';
class TaskEntity {
final String id;
final String title;
final String? description;
final DateTime date;
final String? time;
final Priority priority;
final bool isDone;
final DateTime? createdAt;
final DateTime? updatedAt;
const TaskEntity({
required this.id,
required this.title,
this.description,
required this.date,
this.time,
this.priority = Priority.medium,
this.isDone = false,
this.createdAt,
this.updatedAt,
});
TaskEntity copyWith({
String? id,
String? title,
String? description,
DateTime? date,
String? time,
Priority? priority,
bool? isDone,
DateTime? createdAt,
DateTime? updatedAt,
}) {
return TaskEntity(
id: id ?? this.id,
title: title ?? this.title,
description: description ?? this.description,
date: date ?? this.date,
time: time ?? this.time,
priority: priority ?? this.priority,
isDone: isDone ?? this.isDone,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is TaskEntity && other.id == id;
}
@override
int get hashCode => id.hashCode;
}

View File

@ -0,0 +1,23 @@
enum Priority {
low,
medium,
high;
String get displayName {
switch (this) {
case Priority.low:
return 'Low';
case Priority.medium:
return 'Medium';
case Priority.high:
return 'High';
}
}
static Priority fromString(String value) {
return Priority.values.firstWhere(
(e) => e.name == value.toLowerCase(),
orElse: () => Priority.medium,
);
}
}

View File

@ -0,0 +1,12 @@
import '../../../../core/errors/result.dart';
import '../entities/task_entity.dart';
abstract class TaskRepository {
Future<Result<List<TaskEntity>>> getTasksByDate(DateTime date, {String? status});
Future<Result<TaskEntity>> getTaskById(String id);
Future<Result<TaskEntity>> createTask(TaskEntity task);
Future<Result<TaskEntity>> updateTask(TaskEntity task);
Future<Result<void>> deleteTask(String id);
Future<Result<TaskEntity>> toggleTaskStatus(String id);
Future<Result<TaskEntity>> rescheduleTask(String id, DateTime targetDate);
}

View File

@ -0,0 +1,226 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
class CalendarPage extends StatefulWidget {
const CalendarPage({super.key});
@override
State<CalendarPage> createState() => _CalendarPageState();
}
class _CalendarPageState extends State<CalendarPage> {
late DateTime _focusedMonth;
DateTime? _selectedDate;
@override
void initState() {
super.initState();
_focusedMonth = DateTime.now();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final locale = Localizations.localeOf(context).languageCode;
return Scaffold(
appBar: AppBar(
title: Text(l10n.calendar),
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: () => context.pop(),
),
),
body: Column(
children: [
_MonthNavigation(
month: _focusedMonth,
locale: locale,
onPrevious: () {
setState(() {
_focusedMonth = DateTime(_focusedMonth.year, _focusedMonth.month - 1);
});
},
onNext: () {
setState(() {
_focusedMonth = DateTime(_focusedMonth.year, _focusedMonth.month + 1);
});
},
),
_WeekdayHeaders(locale: locale),
Expanded(
child: _CalendarGrid(
focusedMonth: _focusedMonth,
selectedDate: _selectedDate,
onDateSelected: (date) {
setState(() => _selectedDate = date);
},
),
),
],
),
bottomNavigationBar: _selectedDate != null
? SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: ElevatedButton(
onPressed: () {
final dateStr = DateFormat('yyyy-MM-dd').format(_selectedDate!);
context.go('/?date=$dateStr');
},
child: Text(l10n.goToDay),
),
),
)
: null,
);
}
}
class _MonthNavigation extends StatelessWidget {
final DateTime month;
final String locale;
final VoidCallback onPrevious;
final VoidCallback onNext;
const _MonthNavigation({
required this.month,
required this.locale,
required this.onPrevious,
required this.onNext,
});
@override
Widget build(BuildContext context) {
final monthFormat = DateFormat.yMMMM(locale);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
icon: const Icon(Icons.chevron_left),
onPressed: onPrevious,
),
Text(
monthFormat.format(month),
style: Theme.of(context).textTheme.titleLarge,
),
IconButton(
icon: const Icon(Icons.chevron_right),
onPressed: onNext,
),
],
),
);
}
}
class _WeekdayHeaders extends StatelessWidget {
final String locale;
const _WeekdayHeaders({required this.locale});
@override
Widget build(BuildContext context) {
final weekdays = DateFormat.E(locale);
final today = DateTime.now();
final monday = today.subtract(Duration(days: today.weekday - 1));
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
children: List.generate(7, (index) {
final day = monday.add(Duration(days: index));
return Expanded(
child: Center(
child: Text(
weekdays.format(day).substring(0, 2),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
);
}),
),
);
}
}
class _CalendarGrid extends StatelessWidget {
final DateTime focusedMonth;
final DateTime? selectedDate;
final ValueChanged<DateTime> onDateSelected;
const _CalendarGrid({
required this.focusedMonth,
required this.selectedDate,
required this.onDateSelected,
});
@override
Widget build(BuildContext context) {
final firstDayOfMonth = DateTime(focusedMonth.year, focusedMonth.month, 1);
final lastDayOfMonth = DateTime(focusedMonth.year, focusedMonth.month + 1, 0);
final startOffset = (firstDayOfMonth.weekday - 1) % 7;
final totalDays = startOffset + lastDayOfMonth.day;
final rowCount = (totalDays / 7).ceil();
final today = DateTime.now();
return GridView.builder(
padding: const EdgeInsets.all(8),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 7,
childAspectRatio: 1,
),
itemCount: rowCount * 7,
itemBuilder: (context, index) {
final dayOffset = index - startOffset;
if (dayOffset < 0 || dayOffset >= lastDayOfMonth.day) {
return const SizedBox();
}
final date = DateTime(focusedMonth.year, focusedMonth.month, dayOffset + 1);
final isToday = date.year == today.year &&
date.month == today.month &&
date.day == today.day;
final isSelected = selectedDate != null &&
date.year == selectedDate!.year &&
date.month == selectedDate!.month &&
date.day == selectedDate!.day;
return GestureDetector(
onTap: () => onDateSelected(date),
child: Container(
margin: const EdgeInsets.all(2),
decoration: BoxDecoration(
color: isSelected
? Theme.of(context).colorScheme.primary
: isToday
? Theme.of(context).colorScheme.primaryContainer
: null,
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: Text(
'${dayOffset + 1}',
style: TextStyle(
color: isSelected
? Theme.of(context).colorScheme.onPrimary
: isToday
? Theme.of(context).colorScheme.onPrimaryContainer
: null,
fontWeight: isToday || isSelected ? FontWeight.bold : null,
),
),
),
),
);
},
);
}
}

View File

@ -0,0 +1,180 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import '../../../../core/di/injection_container.dart';
import '../viewmodels/daily_tasks_viewmodel.dart';
import '../widgets/task_tile.dart';
import '../widgets/filter_chips.dart';
class DailyAgendaPage extends StatelessWidget {
const DailyAgendaPage({super.key});
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (_) => getIt<DailyTasksViewModel>()..loadTasks(),
child: const _DailyAgendaView(),
);
}
}
class _DailyAgendaView extends StatelessWidget {
const _DailyAgendaView();
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final vm = context.watch<DailyTasksViewModel>();
final locale = Localizations.localeOf(context).languageCode;
return Scaffold(
appBar: AppBar(
title: Text(l10n.appTitle),
actions: [
IconButton(
icon: const Icon(Icons.calendar_month),
onPressed: () => context.push('/calendar'),
tooltip: l10n.calendar,
),
IconButton(
icon: const Icon(Icons.settings),
onPressed: () => context.push('/settings'),
tooltip: l10n.settings,
),
],
),
body: Column(
children: [
_DateNavigation(
date: vm.selectedDate,
locale: locale,
onPrevious: vm.previousDay,
onNext: vm.nextDay,
),
FilterChips(
currentFilter: vm.filter,
onFilterChanged: vm.setFilter,
),
Expanded(
child: _buildBody(context, vm, l10n),
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () {
final dateStr = DateFormat('yyyy-MM-dd').format(vm.selectedDate);
context.push('/task/new?date=$dateStr');
},
child: const Icon(Icons.add),
),
);
}
Widget _buildBody(BuildContext context, DailyTasksViewModel vm, AppLocalizations l10n) {
switch (vm.status) {
case TasksStatus.initial:
case TasksStatus.loading:
return const Center(child: CircularProgressIndicator());
case TasksStatus.error:
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
vm.failure?.message ?? l10n.errorOccurred,
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: vm.loadTasks,
child: Text(l10n.retry),
),
],
),
);
case TasksStatus.success:
if (vm.tasks.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.task_alt,
size: 64,
color: Theme.of(context).colorScheme.outline,
),
const SizedBox(height: 16),
Text(
l10n.noTasks,
style: Theme.of(context).textTheme.bodyLarge,
),
],
),
);
}
return RefreshIndicator(
onRefresh: vm.loadTasks,
child: ListView.builder(
padding: const EdgeInsets.only(bottom: 80),
itemCount: vm.tasks.length,
itemBuilder: (context, index) {
final task = vm.tasks[index];
return TaskTile(
task: task,
onToggle: () => vm.toggleTask(task.id),
onTap: () => context.push('/task/${task.id}/edit'),
onDelete: () => vm.deleteTask(task.id),
onReschedule: () => vm.rescheduleToTomorrow(task.id),
);
},
),
);
}
}
}
class _DateNavigation extends StatelessWidget {
final DateTime date;
final String locale;
final VoidCallback onPrevious;
final VoidCallback onNext;
const _DateNavigation({
required this.date,
required this.locale,
required this.onPrevious,
required this.onNext,
});
@override
Widget build(BuildContext context) {
final dateFormat = DateFormat.yMMMMEEEEd(locale);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
icon: const Icon(Icons.chevron_left),
onPressed: onPrevious,
),
Expanded(
child: Text(
dateFormat.format(date),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium,
),
),
IconButton(
icon: const Icon(Icons.chevron_right),
onPressed: onNext,
),
],
),
);
}
}

View File

@ -0,0 +1,170 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import '../../../../core/di/injection_container.dart';
import '../../domain/enums/priority.dart';
import '../viewmodels/task_form_viewmodel.dart';
class TaskFormPage extends StatelessWidget {
final String? taskId;
final String? initialDate;
const TaskFormPage({
super.key,
this.taskId,
this.initialDate,
});
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (_) {
final vm = getIt<TaskFormViewModel>();
if (taskId != null) {
vm.initForEdit(taskId!);
} else {
final date = initialDate != null
? DateTime.tryParse(initialDate!)
: null;
vm.initForCreate(date);
}
return vm;
},
child: const _TaskFormView(),
);
}
}
class _TaskFormView extends StatelessWidget {
const _TaskFormView();
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final vm = context.watch<TaskFormViewModel>();
final locale = Localizations.localeOf(context).languageCode;
return Scaffold(
appBar: AppBar(
title: Text(vm.isEditing ? l10n.editTask : l10n.newTask),
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: () => context.pop(),
),
actions: [
TextButton(
onPressed: vm.status == FormStatus.loading
? null
: () async {
final success = await vm.save();
if (success && context.mounted) {
context.pop(true);
}
},
child: Text(l10n.save),
),
],
),
body: vm.status == FormStatus.loading && vm.isEditing
? const Center(child: CircularProgressIndicator())
: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
decoration: InputDecoration(
labelText: '${l10n.title} *',
errorText: vm.titleError != null
? l10n.titleRequired
: null,
border: const OutlineInputBorder(),
),
controller: TextEditingController(text: vm.title)
..selection = TextSelection.collapsed(offset: vm.title.length),
onChanged: vm.setTitle,
textInputAction: TextInputAction.next,
),
const SizedBox(height: 16),
TextField(
decoration: InputDecoration(
labelText: l10n.description,
border: const OutlineInputBorder(),
),
controller: TextEditingController(text: vm.description)
..selection = TextSelection.collapsed(offset: vm.description.length),
onChanged: vm.setDescription,
maxLines: 3,
),
const SizedBox(height: 16),
ListTile(
title: Text(l10n.date),
subtitle: Text(DateFormat.yMMMd(locale).format(vm.date)),
trailing: const Icon(Icons.calendar_today),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: BorderSide(color: Theme.of(context).colorScheme.outline),
),
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: vm.date,
firstDate: DateTime(2020),
lastDate: DateTime(2100),
);
if (picked != null) {
vm.setDate(picked);
}
},
),
const SizedBox(height: 16),
Text(
l10n.priority,
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
SegmentedButton<Priority>(
segments: [
ButtonSegment(
value: Priority.low,
label: Text(l10n.priorityLow),
),
ButtonSegment(
value: Priority.medium,
label: Text(l10n.priorityMedium),
),
ButtonSegment(
value: Priority.high,
label: Text(l10n.priorityHigh),
),
],
selected: {vm.priority},
onSelectionChanged: (selection) {
vm.setPriority(selection.first);
},
),
if (vm.status == FormStatus.error && vm.failure != null) ...[
const SizedBox(height: 24),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
vm.failure!.message,
style: TextStyle(
color: Theme.of(context).colorScheme.onErrorContainer,
),
),
),
],
],
),
),
);
}
}

View File

@ -0,0 +1,137 @@
import 'package:flutter/foundation.dart';
import '../../../../core/errors/failures.dart';
import '../../../../core/logging/app_logger.dart';
import '../../domain/entities/task_entity.dart';
import '../../domain/repositories/task_repository.dart';
enum TasksStatus { initial, loading, success, error }
enum TaskFilter { all, active, completed }
class DailyTasksViewModel extends ChangeNotifier {
final TaskRepository repository;
final AppLogger logger;
DailyTasksViewModel({
required this.repository,
required this.logger,
});
TasksStatus _status = TasksStatus.initial;
List<TaskEntity> _tasks = [];
Failure? _failure;
DateTime _selectedDate = DateTime.now();
TaskFilter _filter = TaskFilter.all;
TasksStatus get status => _status;
List<TaskEntity> get tasks => _filteredTasks;
Failure? get failure => _failure;
DateTime get selectedDate => _selectedDate;
TaskFilter get filter => _filter;
List<TaskEntity> get _filteredTasks {
switch (_filter) {
case TaskFilter.all:
return _tasks;
case TaskFilter.active:
return _tasks.where((t) => !t.isDone).toList();
case TaskFilter.completed:
return _tasks.where((t) => t.isDone).toList();
}
}
int get totalCount => _tasks.length;
int get completedCount => _tasks.where((t) => t.isDone).length;
Future<void> loadTasks() async {
_status = TasksStatus.loading;
_failure = null;
notifyListeners();
final result = await repository.getTasksByDate(_selectedDate);
result.when(
success: (data) {
_tasks = data;
_status = TasksStatus.success;
logger.info('Loaded ${data.length} tasks');
},
error: (failure) {
_failure = failure;
_status = TasksStatus.error;
logger.error('Failed to load tasks: ${failure.message}');
},
);
notifyListeners();
}
void setSelectedDate(DateTime date) {
_selectedDate = DateTime(date.year, date.month, date.day);
loadTasks();
}
void previousDay() {
setSelectedDate(_selectedDate.subtract(const Duration(days: 1)));
}
void nextDay() {
setSelectedDate(_selectedDate.add(const Duration(days: 1)));
}
void setFilter(TaskFilter filter) {
_filter = filter;
notifyListeners();
}
Future<void> toggleTask(String id) async {
final result = await repository.toggleTaskStatus(id);
result.when(
success: (updatedTask) {
final index = _tasks.indexWhere((t) => t.id == id);
if (index != -1) {
_tasks[index] = updatedTask;
notifyListeners();
}
},
error: (failure) {
_failure = failure;
notifyListeners();
},
);
}
Future<void> deleteTask(String id) async {
final result = await repository.deleteTask(id);
result.when(
success: (_) {
_tasks.removeWhere((t) => t.id == id);
notifyListeners();
},
error: (failure) {
_failure = failure;
notifyListeners();
},
);
}
Future<void> rescheduleToTomorrow(String id) async {
final tomorrow = _selectedDate.add(const Duration(days: 1));
final result = await repository.rescheduleTask(id, tomorrow);
result.when(
success: (_) {
_tasks.removeWhere((t) => t.id == id);
notifyListeners();
logger.info('Task rescheduled to tomorrow');
},
error: (failure) {
_failure = failure;
notifyListeners();
},
);
}
}

View File

@ -0,0 +1,151 @@
import 'package:flutter/foundation.dart';
import 'package:uuid/uuid.dart';
import '../../../../core/errors/failures.dart';
import '../../../../core/logging/app_logger.dart';
import '../../domain/entities/task_entity.dart';
import '../../domain/enums/priority.dart';
import '../../domain/repositories/task_repository.dart';
enum FormStatus { initial, loading, success, error }
class TaskFormViewModel extends ChangeNotifier {
final TaskRepository repository;
final AppLogger logger;
TaskFormViewModel({
required this.repository,
required this.logger,
});
FormStatus _status = FormStatus.initial;
Failure? _failure;
TaskEntity? _existingTask;
String _title = '';
String _description = '';
DateTime _date = DateTime.now();
String? _time;
Priority _priority = Priority.medium;
String? _titleError;
FormStatus get status => _status;
Failure? get failure => _failure;
bool get isEditing => _existingTask != null;
String get title => _title;
String get description => _description;
DateTime get date => _date;
String? get time => _time;
Priority get priority => _priority;
String? get titleError => _titleError;
void initForCreate(DateTime? initialDate) {
_existingTask = null;
_title = '';
_description = '';
_date = initialDate ?? DateTime.now();
_time = null;
_priority = Priority.medium;
_status = FormStatus.initial;
notifyListeners();
}
Future<void> initForEdit(String taskId) async {
_status = FormStatus.loading;
notifyListeners();
final result = await repository.getTaskById(taskId);
result.when(
success: (task) {
_existingTask = task;
_title = task.title;
_description = task.description ?? '';
_date = task.date;
_time = task.time;
_priority = task.priority;
_status = FormStatus.initial;
},
error: (failure) {
_failure = failure;
_status = FormStatus.error;
},
);
notifyListeners();
}
void setTitle(String value) {
_title = value;
_titleError = null;
notifyListeners();
}
void setDescription(String value) {
_description = value;
notifyListeners();
}
void setDate(DateTime value) {
_date = value;
notifyListeners();
}
void setTime(String? value) {
_time = value;
notifyListeners();
}
void setPriority(Priority value) {
_priority = value;
notifyListeners();
}
bool validate() {
if (_title.trim().isEmpty) {
_titleError = 'Title is required';
notifyListeners();
return false;
}
return true;
}
Future<bool> save() async {
if (!validate()) return false;
_status = FormStatus.loading;
notifyListeners();
final task = TaskEntity(
id: _existingTask?.id ?? const Uuid().v4(),
title: _title.trim(),
description: _description.trim().isEmpty ? null : _description.trim(),
date: _date,
time: _time,
priority: _priority,
isDone: _existingTask?.isDone ?? false,
createdAt: _existingTask?.createdAt,
);
final result = isEditing
? await repository.updateTask(task)
: await repository.createTask(task);
return result.when(
success: (_) {
_status = FormStatus.success;
logger.info(isEditing ? 'Task updated' : 'Task created');
notifyListeners();
return true;
},
error: (failure) {
_failure = failure;
_status = FormStatus.error;
notifyListeners();
return false;
},
);
}
}

View File

@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import '../viewmodels/daily_tasks_viewmodel.dart';
class FilterChips extends StatelessWidget {
final TaskFilter currentFilter;
final ValueChanged<TaskFilter> onFilterChanged;
const FilterChips({
super.key,
required this.currentFilter,
required this.onFilterChanged,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
FilterChip(
label: Text(l10n.filterAll),
selected: currentFilter == TaskFilter.all,
onSelected: (_) => onFilterChanged(TaskFilter.all),
),
const SizedBox(width: 8),
FilterChip(
label: Text(l10n.filterActive),
selected: currentFilter == TaskFilter.active,
onSelected: (_) => onFilterChanged(TaskFilter.active),
),
const SizedBox(width: 8),
FilterChip(
label: Text(l10n.filterCompleted),
selected: currentFilter == TaskFilter.completed,
onSelected: (_) => onFilterChanged(TaskFilter.completed),
),
],
),
);
}
}

View File

@ -0,0 +1,133 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import '../../domain/entities/task_entity.dart';
import '../../domain/enums/priority.dart';
class TaskTile extends StatelessWidget {
final TaskEntity task;
final VoidCallback onToggle;
final VoidCallback onTap;
final VoidCallback onDelete;
final VoidCallback onReschedule;
const TaskTile({
super.key,
required this.task,
required this.onToggle,
required this.onTap,
required this.onDelete,
required this.onReschedule,
});
Color _getPriorityColor(Priority priority, BuildContext context) {
switch (priority) {
case Priority.high:
return Colors.red.shade700;
case Priority.medium:
return Colors.orange.shade700;
case Priority.low:
return Colors.green.shade700;
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final priorityColor = _getPriorityColor(task.priority, context);
return Dismissible(
key: Key(task.id),
direction: DismissDirection.endToStart,
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
color: Theme.of(context).colorScheme.error,
child: Icon(
Icons.delete,
color: Theme.of(context).colorScheme.onError,
),
),
confirmDismiss: (_) async {
return await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(l10n.deleteTask),
content: Text(l10n.deleteTaskConfirm),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(l10n.cancel),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text(l10n.delete),
),
],
),
) ?? false;
},
onDismissed: (_) => onDelete(),
child: Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Container(
width: 4,
height: 48,
decoration: BoxDecoration(
color: priorityColor,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 12),
Checkbox(
value: task.isDone,
onChanged: (_) => onToggle(),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
task.title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
decoration: task.isDone
? TextDecoration.lineThrough
: null,
color: task.isDone
? Theme.of(context).colorScheme.outline
: null,
),
),
if (task.description != null && task.description!.isNotEmpty)
Text(
task.description!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.outline,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
IconButton(
icon: const Icon(Icons.schedule),
onPressed: onReschedule,
tooltip: l10n.rescheduleToTomorrow,
),
],
),
),
),
),
);
}
}

38
lib/l10n/app_de.arb Normal file
View File

@ -0,0 +1,38 @@
{
"@@locale": "de",
"appTitle": "Agenda Aufgaben",
"calendar": "Kalender",
"settings": "Einstellungen",
"noTasks": "Keine Aufgaben für heute",
"errorOccurred": "Ein Fehler ist aufgetreten",
"retry": "Erneut versuchen",
"newTask": "Neue Aufgabe",
"editTask": "Aufgabe bearbeiten",
"save": "Speichern",
"cancel": "Abbrechen",
"delete": "Löschen",
"title": "Titel",
"titleRequired": "Titel ist erforderlich",
"description": "Beschreibung",
"date": "Datum",
"priority": "Priorität",
"priorityLow": "Niedrig",
"priorityMedium": "Mittel",
"priorityHigh": "Hoch",
"filterAll": "Alle",
"filterActive": "Aktiv",
"filterCompleted": "Erledigt",
"deleteTask": "Aufgabe löschen",
"deleteTaskConfirm": "Möchten Sie diese Aufgabe wirklich löschen?",
"rescheduleToTomorrow": "Auf morgen verschieben",
"goToDay": "Zum Tag",
"general": "Allgemein",
"language": "Sprache",
"systemDefault": "Systemstandard",
"appearance": "Darstellung",
"darkMode": "Design",
"lightMode": "Hell",
"darkModeOption": "Dunkel",
"about": "Über",
"version": "Version"
}

38
lib/l10n/app_en.arb Normal file
View File

@ -0,0 +1,38 @@
{
"@@locale": "en",
"appTitle": "Agenda Tasks",
"calendar": "Calendar",
"settings": "Settings",
"noTasks": "No tasks for today",
"errorOccurred": "An error occurred",
"retry": "Retry",
"newTask": "New Task",
"editTask": "Edit Task",
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"title": "Title",
"titleRequired": "Title is required",
"description": "Description",
"date": "Date",
"priority": "Priority",
"priorityLow": "Low",
"priorityMedium": "Medium",
"priorityHigh": "High",
"filterAll": "All",
"filterActive": "Active",
"filterCompleted": "Completed",
"deleteTask": "Delete Task",
"deleteTaskConfirm": "Are you sure you want to delete this task?",
"rescheduleToTomorrow": "Move to tomorrow",
"goToDay": "Go to day",
"general": "General",
"language": "Language",
"systemDefault": "System default",
"appearance": "Appearance",
"darkMode": "Theme",
"lightMode": "Light",
"darkModeOption": "Dark",
"about": "About",
"version": "Version"
}

Some files were not shown because too many files have changed in this diff Show More