commit 641b90e3216a773ec3b5dc689008e0ef8c7266a5 Author: dp Date: Mon Aug 3 21:51:48 2026 +0200 initial commit diff --git a/.ai/architecture.md b/.ai/architecture.md new file mode 100644 index 0000000..7cc2d3e --- /dev/null +++ b/.ai/architecture.md @@ -0,0 +1,46 @@ +# Architecture — Decisions & Rationale + +## ADR-001: Flutter as framework +**Decision:** Flutter (not React Native or native). +**Reason:** Single codebase for Android + iOS + Web; Dart type safety; +strong Material 3 support; flet-managed Flutter SDK already present. + +## ADR-002: Riverpod for state management +**Decision:** Riverpod with code generation (`riverpod_annotation`). +**Reason:** Compile-time safety; no BuildContext required for providers; +excellent async support (AsyncNotifier); easy testing via ProviderContainer overrides. +Rejected: Bloc (too verbose), Provider (deprecated patterns), GetX (opinionated anti-patterns). + +## ADR-003: Drift (formerly Moor) for local database +**Decision:** Drift + sqlite3_flutter_libs. +**Reason:** Type-safe SQL in Dart; reactive streams out of the box; strong +migration support; works on all Flutter platforms incl. Web (via WASM). +Rejected: Hive (no relations), Isar (less mature migration story). + +## ADR-004: Offline-first, no backend +**Decision:** All data stored locally on device. +**Reason:** Privacy, no auth complexity, works without internet. +Future: optional cloud sync (Supabase/Firebase) can be added as a separate +`sync` feature without touching existing data layer. + +## ADR-005: go_router for navigation +**Decision:** go_router with StatefulShellRoute. +**Reason:** Official Flutter navigation package; deep link support; URL-based +routing works for Web target; StatefulShellRoute preserves bottom-nav state. + +## ADR-006: Feature-first directory structure +**Decision:** `lib/features//{data,domain,presentation}/` +**Reason:** Features can be developed and reviewed in isolation; clear +ownership; easier to add/remove features without touching unrelated code. +Each feature's `data/` holds repositories and DAOs, `domain/` holds models +and providers, `presentation/` holds screens and widgets. + +## ADR-007: freezed for domain models +**Decision:** All domain models use `freezed`. +**Reason:** Immutability enforced at compile time; `copyWith` generated; +`==` and `hashCode` correct; union types for state (e.g. TimerState). + +## ADR-008: Multilingual from day one +**Decision:** `flutter_localizations` + ARB files, no hardcoded strings. +**Reason:** Avoids costly retro-fitting; supports en + de initially; +easily extensible by adding new ARB files. diff --git a/.ai/database.md b/.ai/database.md new file mode 100644 index 0000000..1d64640 --- /dev/null +++ b/.ai/database.md @@ -0,0 +1,81 @@ +# Database — Drift Schema & Conventions + +## Tables + +### projects +| Column | Type | Notes | +|--------------|-----------|------------------------------| +| id | INTEGER | PK, autoincrement | +| name | TEXT | NOT NULL, unique | +| color_value | INTEGER | NOT NULL (ARGB int) | +| description | TEXT | nullable | +| archived_at | DATETIME | nullable; null = active | +| created_at | DATETIME | NOT NULL, default now | + +### time_entries +| Column | Type | Notes | +|-------------|----------|--------------------------------------| +| id | INTEGER | PK, autoincrement | +| project_id | INTEGER | FK → projects.id, NOT NULL | +| start_time | DATETIME | NOT NULL | +| end_time | DATETIME | nullable; null = timer still running | +| duration_s | INTEGER | seconds; computed on stop | +| note | TEXT | nullable | +| created_at | DATETIME | NOT NULL, default now | + +### tags +| Column | Type | Notes | +|--------|---------|-------------------| +| id | INTEGER | PK, autoincrement | +| name | TEXT | NOT NULL, unique | + +### time_entry_tags (junction) +| Column | Type | Notes | +|---------------|---------|--------------------| +| time_entry_id | INTEGER | FK → time_entries | +| tag_id | INTEGER | FK → tags | +| PRIMARY KEY (time_entry_id, tag_id) | | | + +## Drift Setup + +```dart +// lib/core/database/app_database.dart +@DriftDatabase(tables: [Projects, TimeEntries, Tags, TimeEntryTags]) +class AppDatabase extends _$AppDatabase { + AppDatabase() : super(_openConnection()); + + @override + int get schemaVersion => 1; +} + +LazyDatabase _openConnection() { + return LazyDatabase(() async { + final dir = await getApplicationDocumentsDirectory(); + final file = File(p.join(dir.path, 'timetrack.db')); + return NativeDatabase.createInBackground(file); + }); +} +``` + +## DAOs +- `ProjectsDao` — CRUD for projects, watchAll(), watchActive() +- `TimeEntriesDao` — CRUD, watchByProject(), watchByDateRange(), getActiveEntry() +- `TagsDao` — CRUD, watchAll() + +## Migration Strategy +- Schema version starts at `1` +- Increment `schemaVersion` for every breaking change +- Add a `MigrationStrategy` with `onUpgrade` steps +- Never drop columns — use nullable columns for additions +- Test migrations with `drift_dev` schema tests + +## Riverpod Provider + +```dart +@Riverpod(keepAlive: true) +AppDatabase appDatabase(AppDatabaseRef ref) { + final db = AppDatabase(); + ref.onDispose(db.close); + return db; +} +``` diff --git a/.ai/features/export.md b/.ai/features/export.md new file mode 100644 index 0000000..5dd92b8 --- /dev/null +++ b/.ai/features/export.md @@ -0,0 +1,55 @@ +# Feature: Export + +## Supported Formats +| Format | Package | Use case | +|--------|--------------|----------------------------------| +| CSV | `csv` | Spreadsheet import (Excel, etc.) | +| PDF | `pdf` | Printable report | +| JSON | `dart:convert` | Backup / data portability | + +## CSV Schema +``` +id,project,start_time,end_time,duration_seconds,note,tags +42,My Project,2026-07-01T08:00:00,2026-07-01T10:30:00,9000,"Standup",planning|backend +``` +- Date format: ISO 8601 +- Tags: pipe-separated within the cell +- Encoding: UTF-8 with BOM for Excel compatibility + +## PDF Layout +1. Header: App name + export date + selected period +2. Summary table: Project | Total hours | % of total +3. Entries table: Date | Project | Duration | Note | Tags +4. Footer: Generated by Timetrack + +Use `pw.Document` from the `pdf` package. +Render with `pw.Table`, `pw.Text`, `pw.Chart` (optional). + +## JSON Schema +```json +{ + "exportedAt": "2026-07-12T10:00:00Z", + "version": 1, + "projects": [ { "id": 1, "name": "...", "color": -16711936 } ], + "tags": [ { "id": 1, "name": "backend" } ], + "entries": [ + { + "id": 42, "projectId": 1, "startTime": "...", "endTime": "...", + "durationSeconds": 9000, "note": "...", "tagIds": [1] + } + ] +} +``` +JSON export can be used as a full backup and re-imported in a future version. + +## Share Flow +```dart +final file = await _buildExportFile(format); // write to temp dir +await Share.shareXFiles([XFile(file.path)], // share_plus + subject: 'Timetrack Export ${DateFormat.yMd().format(DateTime.now())}'); +``` +Always write to `getTemporaryDirectory()` — never to arbitrary paths. + +## Export Scope +- User selects: date range + format + optional project filter +- Available from Settings screen and Reports screen (share button) diff --git a/.ai/features/reports.md b/.ai/features/reports.md new file mode 100644 index 0000000..4f0e872 --- /dev/null +++ b/.ai/features/reports.md @@ -0,0 +1,52 @@ +# Feature: Reports + +## Views +| View | Period | Grouping | +|---------|---------------|-----------------------| +| Daily | Selected day | Per entry (list) | +| Weekly | Mon–Sun | Per day (bar chart) | +| Monthly | Calendar month| Per week (bar chart) | + +## Chart Data Format (fl_chart BarChart) +```dart +// Weekly: 7 bars (Mon=0 … Sun=6) +List weeklyBars(Map durationByWeekday) { + return List.generate(7, (i) => BarChartGroupData( + x: i, + barRods: [BarChartRodData(toY: durationByWeekday[i]?.inMinutes.toDouble() ?? 0)], + )); +} +``` + +## Key Queries (Drift) +```dart +// Total duration per project in date range +SELECT project_id, SUM(duration_s) AS total +FROM time_entries +WHERE start_time >= :from AND start_time < :to + AND end_time IS NOT NULL +GROUP BY project_id; + +// Duration per weekday in week +SELECT strftime('%w', start_time) AS weekday, SUM(duration_s) AS total +FROM time_entries +WHERE start_time >= :weekStart AND start_time < :weekEnd + AND end_time IS NOT NULL +GROUP BY weekday; +``` + +## ReportsNotifier +- Exposes `selectedPeriod` (day/week/month) and `selectedDate` +- Provides `AsyncValue` via Drift watch stream +- `ReportData` contains: total duration, per-project breakdown, chart bars + +## Filters +- Filter by project (optional, default: all projects) +- Filter by tag (optional) +- Date navigation: previous/next period buttons + +## Summary Cards +Each report view shows: +- Total tracked time for period +- Most tracked project +- Average daily hours (weekly/monthly view) diff --git a/.ai/features/timer.md b/.ai/features/timer.md new file mode 100644 index 0000000..716bdb4 --- /dev/null +++ b/.ai/features/timer.md @@ -0,0 +1,49 @@ +# Feature: Timer + +## Behaviour +- Only **one** active timer at a time. +- Starting a new timer while one is running automatically stops the previous one + and saves the completed `TimeEntry`. +- Timer state persists across app restarts: on launch, read `time_entries` + where `end_time IS NULL` — if found, restore the running timer. + +## State Machine +``` +IDLE ──start()──► RUNNING ──stop()──► IDLE + │ + discard() + │ + IDLE +``` + +## TimerState (freezed union) +```dart +@freezed +class TimerState with _$TimerState { + const factory TimerState.idle() = TimerIdle; + const factory TimerState.running({ + required int entryId, + required DateTime startTime, + required Project project, + String? note, + }) = TimerRunning; +} +``` + +## TimerNotifier responsibilities +1. `start(Project project)` — create TimeEntry (end_time = null), emit running +2. `stop()` — update TimeEntry (set end_time, compute duration_s), emit idle +3. `discard()` — delete the active TimeEntry, emit idle +4. `updateNote(String note)` — update note on active entry +5. `restoreFromDb()` — called on app init; check for open entry + +## Elapsed Time Display +- Use a `Timer.periodic(1 second)` inside the notifier while running. +- Dispose the periodic timer on `stop()` / `discard()`. +- Widget reads `DateTime.now().difference(startTime)` for display. + +## No background service (v1) +- Timer only ticks while app is in foreground. +- If app is killed mid-session, the entry remains open (end_time = null). +- On next launch, `restoreFromDb()` re-attaches to the open entry. +- Future v2: consider `flutter_foreground_task` for background timer. diff --git a/.ai/testing.md b/.ai/testing.md new file mode 100644 index 0000000..d53cb6c --- /dev/null +++ b/.ai/testing.md @@ -0,0 +1,81 @@ +# Testing — Conventions & Patterns + +## Structure +Test files mirror `lib/` under `test/`: +``` +test/ + features/ + timer/ + timer_repository_test.dart + timer_notifier_test.dart + entries/ + entries_repository_test.dart + projects/ + projects_repository_test.dart + reports/ + reports_repository_test.dart + core/ + database/ + app_database_test.dart +``` + +## What to Test + +| Layer | Test type | Tool | +|--------------|---------------|-------------------| +| Repository | Unit | mocktail + drift in-memory | +| Notifier | Unit | ProviderContainer | +| Screen | Widget | flutter_test | +| Navigation | Widget | GoRouter test helpers | + +## Repository Tests — Pattern +Use an **in-memory** Drift database: +```dart +AppDatabase createTestDb() => AppDatabase(NativeDatabase.memory()); +``` +Test every public DAO method: insert, update, delete, watch (stream). + +## Notifier Tests — Pattern +Use `ProviderContainer` with overridden repository: +```dart +final container = ProviderContainer(overrides: [ + projectsRepositoryProvider.overrideWithValue(MockProjectsRepository()), +]); +addTearDown(container.dispose); +``` + +## Widget Tests — Pattern +```dart +await tester.pumpWidget( + ProviderScope( + overrides: [/* mock providers */], + child: const MaterialApp(home: TimerScreen()), + ), +); +``` +- Verify key widgets are present +- Tap interactions and verify state changes +- Use `mocktail` `when()` / `verify()` for interactions + +## Mocktail Conventions +```dart +class MockTimerRepository extends Mock implements TimerRepository {} + +// Setup +when(() => mock.getActiveEntry()).thenAnswer((_) async => null); + +// Verify +verify(() => mock.stopTimer(any())).called(1); +``` + +## Coverage Goal +- Repository methods: 100% +- Notifiers/providers: >80% +- Widget tests: at least smoke test per screen + +## Running Tests +```bash +flutter test # all tests +flutter test test/features/timer/ # single feature +flutter test --coverage # with coverage report +``` diff --git a/.ai/tickets.md b/.ai/tickets.md new file mode 100644 index 0000000..3887a4e --- /dev/null +++ b/.ai/tickets.md @@ -0,0 +1,105 @@ +# Ticket Workflow + +Jede Aufgabe aus einem Implementierungsplan wird als eigenständiges Ticket unter `.tasks/` abgelegt. +Tickets folgen dem Jira-Stil (Story, Task, Bug, Spike) und durchlaufen einen festen Lebenszyklus. + +## Verzeichnisstruktur + +``` +.tasks/ + todo/ ← Backlog: fertig beschrieben, noch nicht begonnen + processing/ ← Aktiv in Bearbeitung (max. 1–2 gleichzeitig) + done/ ← Abgeschlossen (zur Referenz aufbewahren) +``` + +## Ticket-Lebenszyklus + +``` +todo/ → processing/ → done/ → nächstes Ticket +``` + +Die `.md`-Datei wird beim Statuswechsel physisch in den entsprechenden Ordner verschoben. +Niemals den Status nur im Dateiinhalt ändern — der Ordner **ist** der Status. + +## Ticket-ID-Konvention + +``` +TT--.md +``` + +Beispiel: `TT-018-quick-access-grid.md` + +Die nächste freie Nummer ermitteln: + +```bash +ls .tasks/done/ .tasks/processing/ .tasks/todo/ | grep -oP 'TT-\d+' | sort -t- -k2 -n | tail -1 +``` + +## Ticket-Template + +```markdown +# TT-XXX — Titel + +**Type:** Story | Task | Bug | Spike +**Priority:** Highest | High | Medium | Low +**Labels:** komma, getrennt +**Depends on:** TT-XXX, … +**Blocks:** TT-XXX, … + +--- + +## Summary +Ein Absatz: Was wird gemacht und warum. + +## Background +Kontext, der zum Verständnis nötig ist (optional). + +## Acceptance Criteria +- [ ] … + +## Steps +1. … + +## Files to create / modify +- `lib/…` + +## Notes +Hinweise, Fallstricke, verwandte Tickets (optional). +``` + +## Regeln für den Agenten + +1. **Implementierungsplan → Tickets**: Jeder Schritt eines Plans wird zu einem eigenen Ticket in `.tasks/todo/`. +2. **Ein Ticket auf einmal**: Immer nur ein Ticket nach `processing/` verschieben — erst abschließen, dann das nächste beginnen. +3. **Ticket schließen**: Datei nach `done/` verschieben, bevor das nächste Ticket geöffnet wird. +4. **Keine impliziten Aufgaben**: Alles, was getan wird, muss einem Ticket zugeordnet sein. Entsteht Arbeit ad-hoc, zuerst ein Ticket anlegen. +5. **Abhängigkeiten respektieren**: `Depends on`-Felder beachten — blockierte Tickets nicht vor ihren Voraussetzungen beginnen. +6. **`.tasks/README.md` aktualisieren**: Nach jedem neuen Ticket die Übersichtstabelle dort ergänzen. + +## Beispiel-Workflow + +``` +# 1. Implementierungsplan analysieren → Tickets anlegen +touch .tasks/todo/TT-018-quick-access-grid.md +touch .tasks/todo/TT-019-frequent-projects-provider.md + +# 2. Erstes Ticket in Bearbeitung nehmen +mv .tasks/todo/TT-018-quick-access-grid.md .tasks/processing/ + +# 3. Implementierung durchführen … + +# 4. Ticket abschließen +mv .tasks/processing/TT-018-quick-access-grid.md .tasks/done/ + +# 5. Nächstes Ticket beginnen +mv .tasks/todo/TT-019-frequent-projects-provider.md .tasks/processing/ +``` + +## Verwandte Dateien + +| Datei | Inhalt | +|---|---| +| `.tasks/README.md` | Übersichtstabelle aller Tickets mit aktuellem Status | +| `.tasks/todo/` | Offene Tickets | +| `.tasks/processing/` | Tickets in Bearbeitung | +| `.tasks/done/` | Abgeschlossene Tickets (Referenz) | diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..29d5edd --- /dev/null +++ b/.gitignore @@ -0,0 +1,48 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +#lock files +*.lock diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..8f453f7 --- /dev/null +++ b/.metadata @@ -0,0 +1,30 @@ +# 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: "cc0734ac716fbb8b90f3f9db8020958b1553afa7" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: cc0734ac716fbb8b90f3f9db8020958b1553afa7 + base_revision: cc0734ac716fbb8b90f3f9db8020958b1553afa7 + - platform: linux + create_revision: cc0734ac716fbb8b90f3f9db8020958b1553afa7 + base_revision: cc0734ac716fbb8b90f3f9db8020958b1553afa7 + + # 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' diff --git a/.tasks/README.md b/.tasks/README.md new file mode 100644 index 0000000..3bec623 --- /dev/null +++ b/.tasks/README.md @@ -0,0 +1,77 @@ +# .tasks — Ticket System + +Lightweight ticket system for the Timetrack project. +Tickets follow the same conventions as Jira stories/tasks. + +## Folder Structure + +``` +.tasks/ + todo/ ← backlog: ready to be picked up + processing/ ← in active development (max. 1–2 at a time) + done/ ← completed tickets (keep for reference) +``` + +## Ticket Lifecycle + +``` +todo/ → processing/ → done/ +``` + +Move the `.md` file to the corresponding folder when status changes. + +## Ticket ID Convention + +`TT--.md` + +Example: `TT-006-timer-feature-ui.md` + +## Ticket Template + +```markdown +# TT-XXX — Title + +**Type:** Story | Task | Bug | Spike +**Priority:** Highest | High | Medium | Low +**Labels:** comma, separated +**Depends on:** TT-XXX, … +**Blocks:** TT-XXX, … + +--- + +## Summary +One paragraph describing what and why. + +## Acceptance Criteria +- [ ] … + +## Files to create / modify +- `lib/…` +``` + +## Current Tickets + +| ID | Title | Status | +|----|-------|--------| +| TT-001 | Dependencies Setup & Build Runner | ✅ done | +| TT-002 | Drift Database Schema | ✅ done | +| TT-003 | Domain Models (freezed) | ✅ done | +| TT-004 | Repository Implementations | ✅ done | +| TT-005 | Riverpod Providers & Notifiers | ✅ done | +| TT-006 | Timer Feature UI | ✅ done | +| TT-007 | Entries Feature UI | ✅ done | +| TT-008 | Projects Feature UI | ✅ done | +| TT-009 | Reports Feature UI | ✅ done | +| TT-010 | Settings & Export Feature | ✅ done | +| TT-011 | Localisation (i18n) | ✅ done | +| TT-012 | Unit Tests | ✅ done | +| TT-014 | Go APK-Serve-Binary | ✅ done | +| TT-015 | APK-Retention-Script | ✅ done | +| TT-016 | Makefile erstellen | ✅ done | +| TT-017 | Integration-Test | ✅ done | +| TT-018 | Quick-Access Grid: Fallback auf erste N Projekte | ✅ done | +| TT-019 | Quick-Access Grid: Auffüllen mit ältesten Projekten | ✅ done | +| TT-020 | Bug: RangeError in frequentProjects beim Sortieren | ✅ done | +| TT-018 | Go-Server: --web Flag für Static-File-Serving | ✅ done | +| TT-019 | Makefile: serve_web Target + WEB_PORT Variable | ✅ done | +| TT-020 | Makefile: build_and_serve_apk + build_and_serve_web | ✅ done | diff --git a/.tasks/done/TT-001-dependencies-setup.md b/.tasks/done/TT-001-dependencies-setup.md new file mode 100644 index 0000000..4b3f863 --- /dev/null +++ b/.tasks/done/TT-001-dependencies-setup.md @@ -0,0 +1,36 @@ +# TT-001 — Dependencies Setup & Build Runner + +**Type:** Task +**Priority:** Highest +**Labels:** setup, infrastructure +**Depends on:** — +**Blocks:** TT-002, TT-003 + +--- + +## Summary +Run `flutter pub get` to install all declared dependencies and verify the +project compiles. Set up `build_runner` as the code-generation pipeline for +`drift`, `freezed`, and `riverpod_generator`. + +## Background +`pubspec.yaml` has been populated with all required packages but `pub get` +has not been run yet. Generated files (`*.g.dart`, `*.freezed.dart`) do not +exist and will cause compile errors until `build_runner` has run at least once. + +## Acceptance Criteria +- [ ] `flutter pub get` exits with code 0, no resolution conflicts +- [ ] `dart run build_runner build --delete-conflicting-outputs` exits with code 0 +- [ ] `flutter analyze` reports zero errors (warnings acceptable for stubs) +- [ ] `flutter build apk --debug` (or `flutter build web`) succeeds + +## Steps +1. `flutter pub get` +2. `dart run build_runner build --delete-conflicting-outputs` +3. `flutter analyze` +4. Fix any immediate compile errors in generated stubs + +## Notes +- If version conflicts arise, prefer the newer package and update `pubspec.yaml` +- `app_router.g.dart` will fail until `@riverpod` annotation resolves — + this is expected and will be fixed as part of this ticket diff --git a/.tasks/done/TT-002-drift-database-schema.md b/.tasks/done/TT-002-drift-database-schema.md new file mode 100644 index 0000000..a18c44d --- /dev/null +++ b/.tasks/done/TT-002-drift-database-schema.md @@ -0,0 +1,50 @@ +# TT-002 — Drift Database Schema + +**Type:** Story +**Priority:** Highest +**Labels:** database, infrastructure +**Depends on:** TT-001 +**Blocks:** TT-004, TT-005 + +--- + +## Summary +Implement the full Drift database schema in `lib/core/database/app_database.dart` +including all table definitions, DAOs, and the database connection provider. + +## Background +The Drift schema is documented in `.ai/database.md`. The placeholder file +`lib/core/database/app_database.dart` exists but contains no implementation. + +## Data Model +### Tables +| Table | Key columns | +|-------------------|----------------------------------------------------------| +| `projects` | id, name, color_value, description, archived_at, created_at | +| `time_entries` | id, project_id (FK), start_time, end_time, duration_s, note, created_at | +| `tags` | id, name | +| `time_entry_tags` | time_entry_id (FK), tag_id (FK) — composite PK | + +## Acceptance Criteria +- [ ] All 4 tables defined as Drift `Table` classes +- [ ] Foreign key constraints declared and enforced +- [ ] `AppDatabase` annotated with `@DriftDatabase(tables: [...])` +- [ ] `_openConnection()` uses `getApplicationDocumentsDirectory()` + `NativeDatabase.createInBackground` +- [ ] `schemaVersion` = 1, empty `MigrationStrategy` scaffold in place +- [ ] Three DAOs created: `ProjectsDao`, `TimeEntriesDao`, `TagsDao` +- [ ] Each DAO has at minimum: `watchAll()`, `insert()`, `update()`, `delete()` +- [ ] `TimeEntriesDao.getActiveEntry()` returns entry where `end_time IS NULL` +- [ ] Riverpod `@Riverpod(keepAlive: true)` provider for `AppDatabase` +- [ ] `dart run build_runner build` generates schema without errors +- [ ] Unit test: insert + read a `Project` using in-memory DB + +## Files to create / modify +- `lib/core/database/app_database.dart` — full implementation +- `lib/core/database/daos/projects_dao.dart` +- `lib/core/database/daos/time_entries_dao.dart` +- `lib/core/database/daos/tags_dao.dart` +- `test/core/database/app_database_test.dart` + +## Notes +- See `.ai/database.md` for full schema details +- Use `NativeDatabase.memory()` in tests — never the real file DB diff --git a/.tasks/done/TT-003-domain-models-freezed.md b/.tasks/done/TT-003-domain-models-freezed.md new file mode 100644 index 0000000..a8ddae7 --- /dev/null +++ b/.tasks/done/TT-003-domain-models-freezed.md @@ -0,0 +1,100 @@ +# TT-003 — Domain Models (freezed) + +**Type:** Story +**Priority:** High +**Labels:** domain, models +**Depends on:** TT-001 +**Blocks:** TT-004, TT-005, TT-006, TT-007, TT-008 + +--- + +## Summary +Define all domain-level models using `freezed`. These are the immutable data +classes used throughout the app's business logic and UI layer — distinct from +the Drift table companion classes. + +## Models to implement + +### `Project` +```dart +// lib/features/projects/domain/project.dart +@freezed +class Project with _$Project { + const factory Project({ + required int id, + required String name, + required int colorValue, + String? description, + DateTime? archivedAt, + required DateTime createdAt, + }) = _Project; + + factory Project.fromJson(Map json) => _$ProjectFromJson(json); +} +``` + +### `TimeEntry` +```dart +// lib/features/entries/domain/time_entry.dart +@freezed +class TimeEntry with _$TimeEntry { + const factory TimeEntry({ + required int id, + required int projectId, + required DateTime startTime, + DateTime? endTime, + int? durationSeconds, + String? note, + @Default([]) List tags, + required DateTime createdAt, + }) = _TimeEntry; + + factory TimeEntry.fromJson(Map json) => _$TimeEntryFromJson(json); +} +``` + +### `Tag` +```dart +// lib/features/entries/domain/tag.dart +@freezed +class Tag with _$Tag { + const factory Tag({ + required int id, + required String name, + }) = _Tag; + + factory Tag.fromJson(Map json) => _$TagFromJson(json); +} +``` + +### `TimerState` +```dart +// lib/features/timer/domain/timer_state.dart +@freezed +class TimerState with _$TimerState { + const factory TimerState.idle() = TimerIdle; + const factory TimerState.running({ + required int entryId, + required DateTime startTime, + required Project project, + String? note, + }) = TimerRunning; +} +``` + +## Acceptance Criteria +- [ ] All models defined with `@freezed` +- [ ] `fromJson` / `toJson` generated via `json_serializable` +- [ ] `dart run build_runner build` generates `*.freezed.dart` and `*.g.dart` without errors +- [ ] Models have no mutable fields +- [ ] `TimerState` is a union type (sealed), not a single class + +## Files to create +- `lib/features/projects/domain/project.dart` +- `lib/features/entries/domain/time_entry.dart` +- `lib/features/entries/domain/tag.dart` +- `lib/features/timer/domain/timer_state.dart` + +## Notes +- Drift table rows ≠ domain models. Use mapper extension methods to convert between them. +- Add `extension ProjectMapper on ProjectData { Project toDomain() {...} }` in the DAO files. diff --git a/.tasks/done/TT-004-repository-implementations.md b/.tasks/done/TT-004-repository-implementations.md new file mode 100644 index 0000000..ef46d19 --- /dev/null +++ b/.tasks/done/TT-004-repository-implementations.md @@ -0,0 +1,92 @@ +# TT-004 — Repository Implementations + +**Type:** Story +**Priority:** High +**Labels:** data-layer, repositories +**Depends on:** TT-002, TT-003 +**Blocks:** TT-005 + +--- + +## Summary +Implement the concrete repository classes for each feature. Repositories act +as the single access point for the data layer; they delegate to Drift DAOs +and map DB rows to domain models. + +## Repositories to implement + +### `ProjectsRepository` +File: `lib/features/projects/data/projects_repository.dart` + +```dart +abstract class ProjectsRepository { + Stream> watchAll(); + Stream> watchActive(); // archivedAt IS NULL + Future getById(int id); + Future create({required String name, required int colorValue, String? description}); + Future update(Project project); + Future archive(int id); // sets archivedAt = now + Future delete(int id); +} +``` + +### `TimeEntriesRepository` +File: `lib/features/entries/data/entries_repository.dart` + +```dart +abstract class TimeEntriesRepository { + Stream> watchAll(); + Stream> watchByProject(int projectId); + Stream> watchByDateRange(DateTime from, DateTime to); + Future getActiveEntry(); // end_time IS NULL + Future create(TimeEntry entry); + Future update(TimeEntry entry); + Future delete(int id); +} +``` + +### `TagsRepository` +File: `lib/features/entries/data/tags_repository.dart` + +```dart +abstract class TagsRepository { + Stream> watchAll(); + Future findOrCreate(String name); + Future setTagsForEntry(int entryId, List tagNames); + Future> getTagsForEntry(int entryId); +} +``` + +### `ReportsRepository` +File: `lib/features/reports/data/reports_repository.dart` + +```dart +abstract class ReportsRepository { + Future getTotalDuration(DateTime from, DateTime to, {int? projectId}); + Future> getDurationByProject(DateTime from, DateTime to); + Future> getDurationByWeekday(DateTime weekStart); + Future> getDurationByDay(DateTime monthStart); +} +``` + +## Acceptance Criteria +- [ ] Abstract interface + concrete `DriftXxxRepository` implementation for each +- [ ] Riverpod provider for each concrete implementation +- [ ] All DB access goes through DAOs — no raw SQL in repositories +- [ ] Mapper extension methods convert `Drift row → domain model` +- [ ] Unit tests for each repository method using in-memory Drift DB + +## Files to create / modify +- `lib/features/projects/data/projects_repository.dart` (replace stub) +- `lib/features/projects/data/drift_projects_repository.dart` +- `lib/features/entries/data/entries_repository.dart` (replace stub) +- `lib/features/entries/data/drift_entries_repository.dart` +- `lib/features/entries/data/tags_repository.dart` +- `lib/features/entries/data/drift_tags_repository.dart` +- `lib/features/reports/data/reports_repository.dart` (replace stub) +- `lib/features/reports/data/drift_reports_repository.dart` +- `test/features/*/` — unit tests for each + +## Notes +- Keep repositories free of Flutter/UI imports +- Streams from Drift are reactive — repositories expose them directly to Riverpod providers diff --git a/.tasks/done/TT-005-riverpod-providers-notifiers.md b/.tasks/done/TT-005-riverpod-providers-notifiers.md new file mode 100644 index 0000000..c163566 --- /dev/null +++ b/.tasks/done/TT-005-riverpod-providers-notifiers.md @@ -0,0 +1,114 @@ +# TT-005 — Riverpod Providers & Notifiers + +**Type:** Story +**Priority:** High +**Labels:** state-management, riverpod +**Depends on:** TT-003, TT-004 +**Blocks:** TT-006, TT-007, TT-008, TT-009 + +--- + +## Summary +Implement all Riverpod providers and notifiers for each feature. This is the +domain/application layer that connects the data layer (repositories) to the +presentation layer (screens/widgets). + +## Providers to implement + +### Timer Feature +File: `lib/features/timer/domain/timer_notifier.dart` + +```dart +@riverpod +class TimerNotifier extends _$TimerNotifier { + @override + TimerState build(); // reads DB for open entry on init + + Future start(Project project, {String? note}); + Future stop(); + Future discard(); + void updateNote(String note); +} +``` +- Uses a `dart:async Timer.periodic(1s)` while running +- On `build()`: calls `timeEntriesRepository.getActiveEntry()` to restore state +- See `.ai/features/timer.md` for full state-machine spec + +### Projects Feature +File: `lib/features/projects/domain/projects_provider.dart` + +```dart +@riverpod +Stream> activeProjects(ActiveProjectsRef ref); + +@riverpod +Stream> allProjects(AllProjectsRef ref); + +@riverpod +class ProjectsNotifier extends _$ProjectsNotifier { + Future create({required String name, required int colorValue, String? description}); + Future update(Project project); + Future archive(int id); + Future delete(int id); +} +``` + +### Entries Feature +File: `lib/features/entries/domain/entries_provider.dart` + +```dart +@riverpod +Stream> entriesByDateRange( + EntriesByDateRangeRef ref, {required DateTime from, required DateTime to}); + +@riverpod +class EntriesNotifier extends _$EntriesNotifier { + Future create(TimeEntry entry); + Future update(TimeEntry entry); + Future delete(int id); +} +``` + +### Reports Feature +File: `lib/features/reports/domain/reports_provider.dart` + +```dart +@riverpod +class ReportsNotifier extends _$ReportsNotifier { + @override + ReportData build(); + + void selectPeriod(ReportPeriod period); // day | week | month + void goToPrevious(); + void goToNext(); + void filterByProject(int? projectId); +} + +@freezed +class ReportData with _$ReportData { + const factory ReportData({ + required Duration totalDuration, + required Map durationByProject, + required List chartValues, // bars for fl_chart + required DateTime periodStart, + required DateTime periodEnd, + }) = _ReportData; +} +``` + +## Acceptance Criteria +- [ ] All providers use `@riverpod` annotation (code-gen) +- [ ] `TimerNotifier.build()` restores running timer from DB on app start +- [ ] `TimerNotifier` disposes its `dart:async Timer` via `ref.onDispose` +- [ ] All providers inject repositories via `ref.watch(xxxRepositoryProvider)` +- [ ] Unit tests for `TimerNotifier` (start/stop/discard/restore) +- [ ] Unit tests for `ProjectsNotifier` (CRUD operations) + +## Files to create +- `lib/features/timer/domain/timer_notifier.dart` +- `lib/features/projects/domain/projects_provider.dart` +- `lib/features/entries/domain/entries_provider.dart` +- `lib/features/reports/domain/reports_provider.dart` +- `lib/features/reports/domain/report_data.dart` +- `test/features/timer/timer_notifier_test.dart` +- `test/features/projects/projects_notifier_test.dart` diff --git a/.tasks/done/TT-006-timer-feature-ui.md b/.tasks/done/TT-006-timer-feature-ui.md new file mode 100644 index 0000000..9d22dce --- /dev/null +++ b/.tasks/done/TT-006-timer-feature-ui.md @@ -0,0 +1,76 @@ +# TT-006 — Timer Feature UI + +**Type:** Story +**Priority:** High +**Labels:** ui, feature, timer +**Depends on:** TT-005 +**Blocks:** — + +--- + +## Summary +Implement the fully functional Timer screen. This is the primary screen of the +app — users will spend most of their time here. + +## UI Specification + +### TimerScreen layout +``` +┌──────────────────────────────┐ +│ AppBar: "Timer" │ +├──────────────────────────────┤ +│ │ +│ [Project Picker Chip] │ +│ │ +│ 00:00:00 ← elapsed │ +│ │ +│ [Note TextField] │ +│ │ +│ ● START / ■ STOP │ +│ │ +│ [Discard] (only running) │ +│ │ +│ ── Recent entries ──────── │ +│ Today: 2h 30m │ +│ [ Entry 1 ] │ +│ [ Entry 2 ] │ +└──────────────────────────────┘ +``` + +### Widgets to build +| Widget | File | Description | +|--------|------|-------------| +| `TimerScreen` | `timer_screen.dart` | Root screen | +| `TimerDisplay` | `widgets/timer_display.dart` | Large elapsed time counter `HH:MM:SS` | +| `TimerControls` | `widgets/timer_controls.dart` | Start/Stop/Discard FAB area | +| `ProjectPickerSheet` | `widgets/project_picker_sheet.dart` | BottomSheet to select project | +| `ProjectPickerChip` | `widgets/project_picker_chip.dart` | Tappable chip showing selected project | +| `TodaySummaryCard` | `widgets/today_summary_card.dart` | Total hours today | + +## Behaviour +- On mount: `TimerNotifier.build()` already restores running state +- Elapsed display updates every second via `ref.watch(timerNotifierProvider)` +- Tapping project chip → opens `ProjectPickerSheet` +- START disabled if no project selected +- STOP saves entry; DISCARD deletes it +- "Recent entries" shows today's completed entries (last 5) + +## Acceptance Criteria +- [ ] `TimerDisplay` shows `00:00:00` when idle, live elapsed when running +- [ ] Project chip is required before START is enabled +- [ ] Note field is editable while timer is running +- [ ] STOP → entry appears in recent list immediately (reactive stream) +- [ ] DISCARD shows confirmation dialog before deleting +- [ ] `TodaySummaryCard` updates when entries change +- [ ] Widget test: idle state renders START button +- [ ] Widget test: running state renders STOP + DISCARD buttons +- [ ] Widget test: tapping STOP calls `timerNotifier.stop()` + +## Files to create / modify +- `lib/features/timer/presentation/timer_screen.dart` (replace stub) +- `lib/features/timer/presentation/widgets/timer_display.dart` +- `lib/features/timer/presentation/widgets/timer_controls.dart` +- `lib/features/timer/presentation/widgets/project_picker_chip.dart` +- `lib/features/timer/presentation/widgets/project_picker_sheet.dart` +- `lib/features/timer/presentation/widgets/today_summary_card.dart` +- `test/features/timer/timer_screen_test.dart` diff --git a/.tasks/done/TT-007-entries-feature-ui.md b/.tasks/done/TT-007-entries-feature-ui.md new file mode 100644 index 0000000..1ba66b2 --- /dev/null +++ b/.tasks/done/TT-007-entries-feature-ui.md @@ -0,0 +1,73 @@ +# TT-007 — Entries Feature UI + +**Type:** Story +**Priority:** High +**Labels:** ui, feature, entries +**Depends on:** TT-005 +**Blocks:** — + +--- + +## Summary +Implement the Entries screen — a chronological list of all time entries with +the ability to manually add, edit, and delete entries. + +## UI Specification + +### EntriesScreen layout +``` +┌──────────────────────────────┐ +│ AppBar: "Entries" [+ Add] │ +│ [Date range filter chips] │ +├──────────────────────────────┤ +│ Monday, 2026-07-12 │ +│ ┌─────────────────────────┐ │ +│ │ 🟦 My Project 2h 30m│ │ +│ │ 09:00 – 11:30 "Standup"│ │ +│ └─────────────────────────┘ │ +│ ┌─────────────────────────┐ │ +│ │ 🟩 Backend 1h 00m│ │ +│ │ 13:00 – 14:00 │ │ +│ └─────────────────────────┘ │ +│ Total: 3h 30m │ +│ │ +│ Sunday, 2026-07-11 │ +│ ... │ +└──────────────────────────────┘ +``` + +### Widgets to build +| Widget | File | Description | +|--------|------|-------------| +| `EntriesScreen` | `entries_screen.dart` | Root screen with date-grouped list | +| `EntryListTile` | `widgets/entry_list_tile.dart` | Single entry row with swipe-to-delete | +| `DayHeader` | `widgets/day_header.dart` | Section header with date + daily total | +| `EntryFormSheet` | `widgets/entry_form_sheet.dart` | BottomSheet for create/edit | +| `DateRangeFilter` | `widgets/date_range_filter.dart` | Chip row: Today / This week / This month | + +### Entry Form Fields +- Project (required, dropdown/picker) +- Start date + time (DateTimePicker) +- End date + time (DateTimePicker) +- Note (optional, text field) +- Tags (optional, multi-select chip input) +- Duration is auto-computed from start/end + +## Acceptance Criteria +- [ ] Entries grouped by date, newest first +- [ ] Each group shows daily total duration +- [ ] Swipe-to-delete with undo `SnackBar` +- [ ] Tap entry → opens edit form pre-filled +- [ ] "+ Add" button → opens empty create form +- [ ] Form validates: end time must be after start time +- [ ] DateRangeFilter chips change the displayed entries reactively +- [ ] Widget test: empty state shows placeholder text +- [ ] Widget test: list renders entries from provider + +## Files to create / modify +- `lib/features/entries/presentation/entries_screen.dart` (replace stub) +- `lib/features/entries/presentation/widgets/entry_list_tile.dart` +- `lib/features/entries/presentation/widgets/day_header.dart` +- `lib/features/entries/presentation/widgets/entry_form_sheet.dart` +- `lib/features/entries/presentation/widgets/date_range_filter.dart` +- `test/features/entries/entries_screen_test.dart` diff --git a/.tasks/done/TT-008-projects-feature-ui.md b/.tasks/done/TT-008-projects-feature-ui.md new file mode 100644 index 0000000..5c7761f --- /dev/null +++ b/.tasks/done/TT-008-projects-feature-ui.md @@ -0,0 +1,69 @@ +# TT-008 — Projects Feature UI + +**Type:** Story +**Priority:** High +**Labels:** ui, feature, projects +**Depends on:** TT-005 +**Blocks:** TT-006 (project picker) + +--- + +## Summary +Implement the Projects screen where users manage their projects — create, +edit, archive, and delete. Projects are referenced by time entries and the timer. + +## UI Specification + +### ProjectsScreen layout +``` +┌──────────────────────────────┐ +│ AppBar: "Projects" [+ Add] │ +│ [Active] [Archived] ← tabs │ +├──────────────────────────────┤ +│ ┌─────────────────────────┐ │ +│ │ 🟦 My Project → │ │ +│ │ 14h 30m total │ │ +│ └─────────────────────────┘ │ +│ ┌─────────────────────────┐ │ +│ │ 🟩 Backend → │ │ +│ │ 8h 00m total │ │ +│ └─────────────────────────┘ │ +└──────────────────────────────┘ +``` + +### Widgets to build +| Widget | File | Description | +|--------|------|-------------| +| `ProjectsScreen` | `projects_screen.dart` | Tabbed screen: Active / Archived | +| `ProjectListTile` | `widgets/project_list_tile.dart` | Tile with color dot, name, total hours | +| `ProjectFormSheet` | `widgets/project_form_sheet.dart` | Create / edit bottom sheet | +| `ColorPickerRow` | `widgets/color_picker_row.dart` | Row of selectable color circles | + +### ProjectFormSheet Fields +- Name (required, text field, unique validation) +- Color (required, `ColorPickerRow` — 12 preset colors) +- Description (optional, multiline text field) + +### Actions per project (long-press or trailing menu) +- Edit +- Archive / Unarchive +- Delete (only if no time entries reference it; otherwise show warning) + +## Acceptance Criteria +- [ ] Active tab shows non-archived projects +- [ ] Archived tab shows archived projects +- [ ] Each tile shows total tracked hours (all-time) +- [ ] "+" → create form; tap tile → edit form +- [ ] Archive action sets `archivedAt`; moves project to Archived tab immediately +- [ ] Delete blocked with dialog if project has entries +- [ ] Color picker shows ≥ 10 preset colors +- [ ] Name uniqueness validated before save +- [ ] Widget test: empty state renders "No projects yet" +- [ ] Widget test: create form saves and tile appears + +## Files to create / modify +- `lib/features/projects/presentation/projects_screen.dart` (replace stub) +- `lib/features/projects/presentation/widgets/project_list_tile.dart` +- `lib/features/projects/presentation/widgets/project_form_sheet.dart` +- `lib/features/projects/presentation/widgets/color_picker_row.dart` +- `test/features/projects/projects_screen_test.dart` diff --git a/.tasks/done/TT-009-reports-feature-ui.md b/.tasks/done/TT-009-reports-feature-ui.md new file mode 100644 index 0000000..6750ba0 --- /dev/null +++ b/.tasks/done/TT-009-reports-feature-ui.md @@ -0,0 +1,82 @@ +# TT-009 — Reports Feature UI + +**Type:** Story +**Priority:** High +**Labels:** ui, feature, reports, charts +**Depends on:** TT-005 +**Blocks:** — + +--- + +## Summary +Implement the Reports screen with day / week / month views and bar charts +powered by `fl_chart`. Users can navigate between periods and filter by project. + +## UI Specification + +### ReportsScreen layout +``` +┌──────────────────────────────┐ +│ AppBar: "Reports" [export] │ +│ [Day] [Week] [Month] tabs │ +├──────────────────────────────┤ +│ ◀ Mon 7 Jul – Sun 13 Jul ▶ │ +│ │ +│ Total: 22h 15m │ +│ │ +│ ┌────────────────────────┐ │ +│ │ Bar chart (fl_chart) │ │ +│ │ Mon Tue Wed Thu Fri │ │ +│ └────────────────────────┘ │ +│ │ +│ By project: │ +│ 🟦 My Project 14h (63%) │ +│ 🟩 Backend 8h (36%) │ +└──────────────────────────────┘ +``` + +### Views +| Tab | X-axis | Bar value | +|-------|-----------------|-------------------| +| Day | Hours (0–23) | Minutes per hour | +| Week | Weekdays (Mo–Su)| Hours per day | +| Month | Weeks 1–5 | Hours per week | + +### Widgets to build +| Widget | File | Description | +|--------|------|-------------| +| `ReportsScreen` | `reports_screen.dart` | Tabbed screen + period navigator | +| `PeriodNavigator` | `widgets/period_navigator.dart` | ◀ Period label ▶ | +| `DurationBarChart` | `widgets/duration_bar_chart.dart` | fl_chart BarChart wrapper | +| `ProjectBreakdownList` | `widgets/project_breakdown_list.dart` | Per-project totals + % | +| `SummaryHeader` | `widgets/summary_header.dart` | Total duration + avg daily | + +### Period Navigation +- "Previous" / "Next" arrows call `reportsNotifier.goToPrevious()` / `.goToNext()` +- Tapping the period label opens a `DateRangePicker` (week/month snapped) + +### Project Filter +- Optional `DropdownButton` in AppBar actions to filter by project +- Default: all projects + +## Acceptance Criteria +- [ ] Three tabs: Day / Week / Month with distinct chart shapes +- [ ] Period navigator updates charts and summary reactively +- [ ] `DurationBarChart` renders correct number of bars per period +- [ ] Y-axis labeled in hours; bars show duration for that bucket +- [ ] Project breakdown list sorted by duration descending +- [ ] Export button in AppBar triggers export flow (see TT-010) +- [ ] Widget test: week view renders 7 bars +- [ ] Widget test: navigating period changes displayed label + +## Files to create / modify +- `lib/features/reports/presentation/reports_screen.dart` (replace stub) +- `lib/features/reports/presentation/widgets/period_navigator.dart` +- `lib/features/reports/presentation/widgets/duration_bar_chart.dart` +- `lib/features/reports/presentation/widgets/project_breakdown_list.dart` +- `lib/features/reports/presentation/widgets/summary_header.dart` +- `test/features/reports/reports_screen_test.dart` + +## Notes +- See `.ai/features/reports.md` for Drift query details and chart data format +- `fl_chart` docs: https://pub.dev/packages/fl_chart diff --git a/.tasks/done/TT-010-settings-export-feature.md b/.tasks/done/TT-010-settings-export-feature.md new file mode 100644 index 0000000..60e0638 --- /dev/null +++ b/.tasks/done/TT-010-settings-export-feature.md @@ -0,0 +1,98 @@ +# TT-010 — Settings & Export Feature + +**Type:** Story +**Priority:** Medium +**Labels:** ui, feature, settings, export +**Depends on:** TT-005 +**Blocks:** — + +--- + +## Summary +Implement the Settings screen covering language selection, theme override, +and the full data export flow (CSV + PDF + JSON) via the system share sheet. + +## UI Specification + +### SettingsScreen sections +``` +┌──────────────────────────────┐ +│ AppBar: "Settings" │ +├──────────────────────────────┤ +│ ▶ APPEARANCE │ +│ Theme [System ▼] │ +│ Language [English ▼] │ +│ │ +│ ▶ DATA │ +│ Export data → │ +│ Import / Restore → │ +│ │ +│ ▶ ABOUT │ +│ Version 0.1.0 │ +│ Licenses │ +└──────────────────────────────┘ +``` + +### Export Flow (ExportSheet) +Triggered from Settings → "Export data" or Reports → AppBar export button. + +``` +1. Select format: [CSV] [PDF] [JSON] +2. Select range: [Today] [This week] [This month] [Custom] +3. Filter project: [All projects ▼] +4. → Share button → share_plus share sheet +``` + +## Export Implementation + +### CSV +- Columns: `id, project, start_time, end_time, duration_seconds, note, tags` +- Tags: pipe-separated +- Encoding: UTF-8 with BOM +- File name: `timetrack_export_2026-07-12.csv` + +### PDF +- Header: App name, export date, period +- Summary table: Project | Total hours | % +- Entries table: Date | Project | Duration | Note +- File name: `timetrack_report_2026-07-12.pdf` + +### JSON +- Full backup schema — see `.ai/features/export.md` +- File name: `timetrack_backup_2026-07-12.json` + +## Settings Persistence +Store user preferences in `shared_preferences`: +| Key | Type | Default | +|-----|------|---------| +| `theme_mode` | String | `system` | +| `locale` | String | device locale | + +Expose as Riverpod `@riverpod` providers: +- `themeModeProvider` — reads / writes `ThemeMode` +- `localeProvider` — reads / writes `Locale` +Wire into `app.dart` `MaterialApp.router`. + +## Acceptance Criteria +- [ ] Theme dropdown: System / Light / Dark — changes app theme immediately +- [ ] Language dropdown: English / Deutsch — changes app locale immediately +- [ ] Export sheet: all 3 formats functional, share sheet opens +- [ ] Export produces non-empty files for each format +- [ ] CSV UTF-8 BOM present (verify with hex check in test) +- [ ] PDF renders without exception for non-empty date range +- [ ] JSON is valid and matches backup schema +- [ ] Preferences persist across app restart (shared_preferences) +- [ ] Widget test: theme selection updates `themeModeProvider` + +## Files to create / modify +- `lib/features/settings/presentation/settings_screen.dart` (replace stub) +- `lib/features/settings/presentation/widgets/export_sheet.dart` +- `lib/features/settings/domain/settings_provider.dart` +- `lib/features/settings/data/export_service.dart` +- `test/features/settings/export_service_test.dart` + +## Dependencies to add +```yaml +shared_preferences: ^2.3.3 +``` +Add to `pubspec.yaml`. diff --git a/.tasks/done/TT-011-localisation-i18n.md b/.tasks/done/TT-011-localisation-i18n.md new file mode 100644 index 0000000..cead6a0 --- /dev/null +++ b/.tasks/done/TT-011-localisation-i18n.md @@ -0,0 +1,131 @@ +# TT-011 — Localisation (i18n) + +**Type:** Story +**Priority:** Medium +**Labels:** i18n, l10n, infrastructure +**Depends on:** TT-001 +**Blocks:** TT-006, TT-007, TT-008, TT-009, TT-010 + +--- + +## Summary +Set up `flutter_localizations` with ARB files for English (`en`) and German +(`de`). All UI strings must be externalised — no hardcoded display text in +widget files. + +## Setup + +### l10n.yaml (project root) +```yaml +arb-dir: lib/core/l10n +template-arb-file: app_en.arb +output-localization-file: app_localizations.dart +output-class: AppLocalizations +``` + +### File structure +``` +lib/core/l10n/ + app_en.arb ← template (English) + app_de.arb ← German translations +``` + +Generated: `lib/core/l10n/app_localizations.dart` (do not edit manually) + +### Usage in widgets +```dart +import 'package:timetrack/core/l10n/app_localizations.dart'; +// ... +Text(AppLocalizations.of(context)!.timerStart) +``` + +## Strings to externalise (minimum set) + +### Navigation +```json +"navTimer": "Timer", +"navEntries": "Entries", +"navProjects": "Projects", +"navReports": "Reports", +"navSettings": "Settings" +``` + +### Timer +```json +"timerStart": "Start", +"timerStop": "Stop", +"timerDiscard": "Discard", +"timerDiscardConfirm": "Discard this entry?", +"timerSelectProject": "Select project", +"timerAddNote": "Add a note…", +"timerTodayTotal": "Today: {duration}" +``` + +### Entries +```json +"entriesTitle": "Entries", +"entriesEmpty": "No entries yet", +"entriesAdd": "Add entry", +"entriesDeleteUndo": "Entry deleted", +"entriesUndo": "Undo" +``` + +### Projects +```json +"projectsTitle": "Projects", +"projectsEmpty": "No projects yet", +"projectsAdd": "Add project", +"projectsActive": "Active", +"projectsArchived": "Archived", +"projectsName": "Name", +"projectsDescription": "Description", +"projectsArchive": "Archive", +"projectsDeleteBlocked": "Cannot delete — project has entries" +``` + +### Reports +```json +"reportsTitle": "Reports", +"reportsDay": "Day", +"reportsWeek": "Week", +"reportsMonth": "Month", +"reportsTotal": "Total", +"reportsNoData": "No data for this period" +``` + +### Settings / Export +```json +"settingsTitle": "Settings", +"settingsTheme": "Theme", +"settingsThemeSystem": "System", +"settingsThemeLight": "Light", +"settingsThemeDark": "Dark", +"settingsLanguage": "Language", +"settingsExport": "Export data", +"exportFormat": "Format", +"exportRange": "Date range", +"exportShare": "Share" +``` + +### Common +```json +"cancel": "Cancel", +"save": "Save", +"delete": "Delete", +"edit": "Edit", +"confirm": "Confirm" +``` + +## Acceptance Criteria +- [ ] `l10n.yaml` present at project root +- [ ] `app_en.arb` contains all strings listed above +- [ ] `app_de.arb` contains all German translations (no English fallback strings) +- [ ] `flutter gen-l10n` (or `flutter pub get`) generates `app_localizations.dart` +- [ ] Zero hardcoded display strings in any `presentation/` file +- [ ] Switching language in Settings updates all visible strings immediately +- [ ] Widget test: locale override renders German strings + +## Files to create +- `l10n.yaml` +- `lib/core/l10n/app_en.arb` +- `lib/core/l10n/app_de.arb` diff --git a/.tasks/done/TT-012-unit-tests.md b/.tasks/done/TT-012-unit-tests.md new file mode 100644 index 0000000..620467c --- /dev/null +++ b/.tasks/done/TT-012-unit-tests.md @@ -0,0 +1,96 @@ +# TT-012 — Unit Tests + +**Type:** Task +**Priority:** Medium +**Labels:** testing, unit-tests +**Depends on:** TT-004, TT-005 +**Blocks:** — + +--- + +## Summary +Write comprehensive unit tests for all repository methods and Riverpod +notifiers. See `.ai/testing.md` for full conventions. + +## Scope + +### Database / Repository tests +All tests use `NativeDatabase.memory()` — never the real file DB. + +| Test file | What to test | +|-----------|-------------| +| `test/core/database/app_database_test.dart` | Schema creation, schemaVersion | +| `test/features/projects/projects_repository_test.dart` | CRUD, watchActive stream, archive | +| `test/features/entries/entries_repository_test.dart` | CRUD, watchByDateRange, getActiveEntry | +| `test/features/entries/tags_repository_test.dart` | findOrCreate, setTagsForEntry | +| `test/features/reports/reports_repository_test.dart` | getDurationByProject, getDurationByWeekday | + +### Notifier tests +All notifier tests use `ProviderContainer` with mocked repositories. + +| Test file | What to test | +|-----------|-------------| +| `test/features/timer/timer_notifier_test.dart` | start, stop, discard, restore from open entry | +| `test/features/projects/projects_notifier_test.dart` | create, update, archive, delete | +| `test/features/entries/entries_notifier_test.dart` | create, update, delete | + +## Test Patterns + +### Repository test pattern +```dart +late AppDatabase db; +late DriftProjectsRepository repository; + +setUp(() { + db = AppDatabase(NativeDatabase.memory()); + repository = DriftProjectsRepository(db); +}); + +tearDown(() => db.close()); + +test('insert and retrieve project', () async { + await repository.create(name: 'Test', colorValue: 0xFF0000FF); + final projects = await repository.watchActive().first; + expect(projects, hasLength(1)); + expect(projects.first.name, equals('Test')); +}); +``` + +### Notifier test pattern +```dart +late ProviderContainer container; +late MockProjectsRepository mockRepo; + +setUp(() { + mockRepo = MockProjectsRepository(); + container = ProviderContainer(overrides: [ + projectsRepositoryProvider.overrideWithValue(mockRepo), + ]); +}); + +tearDown(container.dispose); +``` + +## Coverage Goals +| Layer | Target | +|-------|--------| +| Repository (CRUD methods) | 100% | +| Notifiers | ≥ 80% | +| Export service | ≥ 70% | + +## Acceptance Criteria +- [ ] All listed test files exist and pass +- [ ] `flutter test` exits with code 0 +- [ ] No test uses the real file database +- [ ] Each repository test covers: insert, read, update, delete, stream emission +- [ ] `TimerNotifier` test covers: start → running state, stop → idle + entry saved, + discard → idle + entry deleted, build with open entry → running state restored +- [ ] `flutter test --coverage` generates coverage report + +## Running tests +```bash +flutter test # all tests +flutter test test/features/timer/ # single feature +flutter test --coverage # with lcov coverage +genhtml coverage/lcov.info -o coverage/html # HTML report +``` diff --git a/.tasks/done/TT-013-widget-tests.md b/.tasks/done/TT-013-widget-tests.md new file mode 100644 index 0000000..b801b81 --- /dev/null +++ b/.tasks/done/TT-013-widget-tests.md @@ -0,0 +1,93 @@ +# TT-013 — Widget Tests + +**Type:** Task +**Priority:** Medium +**Labels:** testing, widget-tests +**Depends on:** TT-006, TT-007, TT-008, TT-009, TT-010, TT-011 +**Blocks:** — + +--- + +## Summary +Write widget tests for each feature screen. Tests verify correct rendering +of UI states, user interactions, and provider integration. All provider +dependencies are overridden with mocks via `mocktail`. + +## Scope + +| Test file | Screen | Key scenarios | +|-----------|--------|---------------| +| `test/features/timer/timer_screen_test.dart` | TimerScreen | idle state, running state, start tap, stop tap | +| `test/features/entries/entries_screen_test.dart` | EntriesScreen | empty state, list renders, swipe delete, add form | +| `test/features/projects/projects_screen_test.dart` | ProjectsScreen | empty state, tile renders, create form, archive | +| `test/features/reports/reports_screen_test.dart` | ReportsScreen | week tab, period navigation, chart renders | +| `test/features/settings/settings_screen_test.dart` | SettingsScreen | theme picker, language picker, export sheet opens | + +## Widget Test Pattern +```dart +Widget buildTestApp({List overrides = const []}) { + return ProviderScope( + overrides: overrides, + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const TimerScreen(), + ), + ); +} + +testWidgets('shows START button when idle', (tester) async { + when(() => mockTimerNotifier.build()).thenReturn(const TimerState.idle()); + + await tester.pumpWidget(buildTestApp(overrides: [ + timerNotifierProvider.overrideWith(() => mockTimerNotifier), + ])); + + expect(find.text('Start'), findsOneWidget); + expect(find.text('Stop'), findsNothing); +}); +``` + +## Key Widget Test Cases per Screen + +### TimerScreen +- [ ] Idle: START visible, STOP hidden, elapsed shows `00:00:00` +- [ ] Running: STOP visible, DISCARD visible, project name shown +- [ ] Tap START without project → button remains disabled +- [ ] Tap STOP → `timerNotifier.stop()` called once +- [ ] Tap DISCARD → confirmation dialog appears + +### EntriesScreen +- [ ] Empty: placeholder text visible +- [ ] With entries: `EntryListTile` rendered for each +- [ ] Swipe left on entry → delete action visible +- [ ] Tap "+" → `EntryFormSheet` appears +- [ ] Date filter chip "Today" → filters visible entries + +### ProjectsScreen +- [ ] Empty Active tab: "No projects yet" visible +- [ ] Tile shows project name and color dot +- [ ] Tap tile → edit form opens pre-filled +- [ ] Archive action → tile moves to Archived tab + +### ReportsScreen +- [ ] Week tab shows 7 bar groups +- [ ] Tap ▶ → period label advances by one week +- [ ] Total duration card shows formatted duration + +### SettingsScreen +- [ ] Theme dropdown shows current selection +- [ ] Changing theme → `themeModeProvider` updated +- [ ] Tap "Export data" → `ExportSheet` bottom sheet appears + +## Acceptance Criteria +- [ ] All listed widget test files exist and pass with `flutter test` +- [ ] Each screen has ≥ 3 test cases +- [ ] No test relies on real DB or real providers +- [ ] Tests run in < 30 seconds total (no `sleep` / real async delays) +- [ ] `pumpAndSettle` used after async interactions + +## Notes +- Use `find.byType()`, `find.text()`, `find.byKey()` — avoid pixel-exact finders +- Add semantic labels / keys to interactive widgets to simplify test selectors +- Golden tests (screenshot comparison) are optional for v1 diff --git a/.tasks/done/TT-014-go-serve-binary.md b/.tasks/done/TT-014-go-serve-binary.md new file mode 100644 index 0000000..369703c --- /dev/null +++ b/.tasks/done/TT-014-go-serve-binary.md @@ -0,0 +1,53 @@ +# TT-014 — Go APK-Serve-Binary + +**Type:** Story +**Priority:** High +**Labels:** go, tooling, server +**Depends on:** — +**Blocks:** TT-016 (serve_apk Make-Target) + +--- + +## Summary +Implementiere einen portablen HTTP-Server in Go der eine APK per WLAN bereitstellt. +Kein Python, kein Flask, keine externen Abhängigkeiten — nur Go Standard-Library. +Das kompilierte Binary `scripts/serve/serve` wird von `make serve_apk` gestartet. + +## Funktionsumfang +| Feature | Detail | +|---------|--------| +| Landingpage `/` | HTML-Template mit App-Name, Version, Größe, Build-Datum, SHA256 | +| Download `/download` | APK als Streaming-Response mit Content-Disposition | +| SHA256-Endpoint `/sha256` | JSON: `{"sha256":"…","filename":"…"}` | +| No-Cache-Header | `Cache-Control: no-store` auf allen Responses | +| QR-Code im Terminal | `qrencode -t UTF8 ` via `os/exec` | +| Lokale IP | UDP-Trick: Verbindung zu 8.8.8.8:80, eigene IP ablesen | +| CLI-Flags | `--dir build/apk` (default), `--port 8888`, `--debug` | +| Versioninfo | APK-Dateiname parsen: `timetrack-0.1.0+3.apk` → Version + Build-Nummer | +| SHA256 Sidecar | `.sha256`-Datei neben APK lesen (Format: ` `) | + +## HTML-Template +- Design: modernes Card-Layout, dunkles Theme (Timetrack-Blau `#2563EB`) +- Felder: Version, Größe MB, Build-Datum, Plattform +- Verifizierungs-Sektion: SHA256 anzeigen + JS-Datei-Hash-Prüfung (Web Crypto API) +- Installationshinweis für Android (Sicherheitseinstellungen) + +## Go-Modul +``` +scripts/serve/ + go.mod (module timetrack/serve, go 1.21, keine externen deps) + main.go (alles in einem File — überschaubar) +``` + +## Acceptance Criteria +- [ ] `go build -o serve ./scripts/serve/` kompiliert ohne Fehler +- [ ] `./serve --dir build/apk --port 8888` startet Server +- [ ] Landingpage zeigt korrekte Metadaten wenn APK vorhanden +- [ ] `/download` liefert APK mit korrektem MIME-Type +- [ ] QR-Code erscheint im Terminal (falls `qrencode` verfügbar) +- [ ] Server startet auch wenn kein `qrencode` vorhanden (graceful fallback) +- [ ] Klarer Fehler wenn kein `.apk` im `--dir` gefunden + +## Files to create +- `scripts/serve/go.mod` +- `scripts/serve/main.go` diff --git a/.tasks/done/TT-015-apk-retention-script.md b/.tasks/done/TT-015-apk-retention-script.md new file mode 100644 index 0000000..0def94b --- /dev/null +++ b/.tasks/done/TT-015-apk-retention-script.md @@ -0,0 +1,57 @@ +# TT-015 — APK-Retention-Script + +**Type:** Task +**Priority:** High +**Labels:** tooling, shell +**Depends on:** — +**Blocks:** TT-016 (build_android Make-Target) + +--- + +## Summary +Shell-Script `scripts/apk_retention.sh` das nach jedem APK-Build die Build-Historie +bereinigt. Wird vom Makefile als letzter Schritt von `build_android` aufgerufen. + +## Retention-Strategie + +### Regel 1 — Aktuelle Minor-Version +In der Minor-Version die gerade entwickelt wird (höchste Minor): +- Behalte die **letzten 15 APKs** nach Build-Nummer +- Lösche ältere + +### Regel 2 — Abgeschlossene Minor-Versionen +Für ältere Minor-Versionen (z.B. 0.1.x wenn aktuell 0.2.x entwickelt wird): +- Behalte immer genau **eine APK pro Minor-Version** (die mit der höchsten Build-Nummer) +- Das ist der "Release-Marker" für diese Version + +### Beispiel +``` +Aktuell: 0.1.x (Build +1 bis +20 vorhanden) +→ Behalte +6 bis +20 (15 neueste) +→ Lösche +1 bis +5 + +Später: 0.2.x existiert, 0.1.x hat +1 bis +20 +→ 0.1.x: Behalte nur +20 (höchste = Release-Marker) +→ 0.2.x: Behalte letzte 15 +``` + +## APK-Dateiformat +`timetrack-{VERSION}+{BUILD}.apk` +Beispiele: +- `timetrack-0.1.0+1.apk` +- `timetrack-0.1.0+15.apk` +- `timetrack-0.2.0+3.apk` + +## Acceptance Criteria +- [ ] Script nimmt `--dir` als Argument (default: `build/apk`) +- [ ] Erkennt Minor-Versionen korrekt (0.1, 0.2, etc.) +- [ ] Behält letzte 15 in aktueller Minor-Version +- [ ] Behält je eine (neueste) APK für ältere Minor-Versionen +- [ ] Löscht zugehörige `.sha256`-Dateien mitgleich +- [ ] Gibt Liste der gelöschten Dateien aus +- [ ] Gibt Liste der behaltenen Dateien aus +- [ ] Tut nichts wenn weniger als 16 APKs vorhanden (keine Löschung nötig) +- [ ] Löscht niemals den `timetrack.apk`-Symlink + +## Files to create +- `scripts/apk_retention.sh` (ausführbar, `chmod +x`) diff --git a/.tasks/done/TT-016-makefile.md b/.tasks/done/TT-016-makefile.md new file mode 100644 index 0000000..78bdf12 --- /dev/null +++ b/.tasks/done/TT-016-makefile.md @@ -0,0 +1,106 @@ +# TT-016 — Makefile erstellen + +**Type:** Story +**Priority:** High +**Labels:** makefile, tooling, build +**Depends on:** TT-014 (Go-Binary), TT-015 (Retention-Script) +**Blocks:** TT-017 (Integration-Test) + +--- + +## Summary +Erstelle das zentrale `Makefile` mit allen Build-, Test- und Deploy-Targets +für das Timetrack Flutter-Projekt. + +## Variablen +```makefile +FLUTTER := $(HOME)/flutter/3.41.7/bin/flutter +JAVA_HOME ?= $(HOME)/java/17.0.13+11 +ANDROID_HOME ?= $(HOME)/Android/sdk +ADB := $(ANDROID_HOME)/platform-tools/adb + +BUILD_VERSION ?= 0.1.0 +BUILD_NUMBER ?= 1 +PORT ?= 8888 + +APK_NAME := timetrack-$(BUILD_VERSION)+$(BUILD_NUMBER).apk +APK_DIR := build/apk +APK_LATEST := $(APK_DIR)/timetrack.apk +SERVE_BIN := scripts/serve/serve +``` + +## Targets + +### Dev-Targets +| Target | Befehl | Beschreibung | +|--------|--------|-------------| +| `help` | — | Alle Targets mit Beschreibung ausgeben | +| `install` | `flutter pub get` | Dependencies installieren | +| `generate` | `flutter pub run build_runner build --delete-conflicting-outputs` | Code-Generierung | +| `test` | `flutter test` | Alle Tests ausführen | +| `test_coverage` | `flutter test --coverage` + genhtml | Tests mit Coverage-Report | +| `analyze` | `flutter analyze` | Statische Analyse | +| `format` | `dart format lib/ test/` | Code formatieren | +| `clean` | `flutter clean` + `rm -rf coverage/` | Build-Artefakte löschen | + +### Build-Targets +| Target | Beschreibung | +|--------|-------------| +| `build_serve` | Go-Binary `scripts/serve/serve` kompilieren | +| `build_android` | APK (release) + SHA256 + Symlink + Retention | +| `build_aab` | App Bundle für Play Store | +| `build_linux` | Linux Desktop-App | +| `build_web` | Web-App (debug) | +| `build_all` | APK + Linux + Web nacheinander | + +### Deploy-Targets +| Target | Beschreibung | +|--------|-------------| +| `install_android` | APK via ADB auf verbundenes Gerät installieren | +| `deploy_android` | `build_android` + `install_android` | +| `serve_apk` | Go-Server starten (kompiliert Binary falls nötig) | + +### Info-Targets +| Target | Beschreibung | +|--------|-------------| +| `db_schema` | Drift-Schema-Version und Tabellennamen anzeigen | + +## build_android — Detail-Logik +```makefile +build_android: + # 1. JAVA_HOME und ANDROID_HOME prüfen + # 2. flutter build apk --release --build-name --build-number + # 3. mkdir -p $(APK_DIR) + # 4. cp build/app/outputs/flutter-apk/app-release.apk $(APK_DIR)/$(APK_NAME) + # 5. sha256sum $(APK_DIR)/$(APK_NAME) | tee $(APK_DIR)/$(APK_NAME).sha256 + # 6. ln -sf $(APK_NAME) $(APK_LATEST) + # 7. bash scripts/apk_retention.sh --dir $(APK_DIR) + # 8. Ausgabe: Pfad, Größe, SHA256, serve_apk-Hinweis +``` + +## serve_apk — Dependency auf Binary +```makefile +serve_apk: $(SERVE_BIN) # Auto-kompilieren falls binary fehlt + $(SERVE_BIN) --dir $(APK_DIR) --port $(PORT) + +$(SERVE_BIN): + cd scripts/serve && go build -o serve . +``` + +## Acceptance Criteria +- [ ] `make help` listet alle Targets mit Beschreibung +- [ ] `make install` führt `flutter pub get` aus +- [ ] `make test` führt alle Flutter-Tests aus +- [ ] `make analyze` führt `flutter analyze` aus +- [ ] `make format` formatiert Dart-Code +- [ ] `make clean` löscht Build-Artefakte +- [ ] `make build_serve` kompiliert das Go-Binary +- [ ] `make serve_apk` kompiliert Binary automatisch falls fehlend +- [ ] `make build_linux` prüft `ld.lld` und bricht mit Hinweis ab falls fehlend +- [ ] `make build_web` läuft durch (debug-Build) +- [ ] `make build_all` führt APK + Linux + Web nacheinander aus +- [ ] `make db_schema` zeigt Schema-Version und Tabellen an +- [ ] `BUILD_VERSION=0.2.0 BUILD_NUMBER=5 make build_android` nutzt die übergebenen Werte + +## Files to create +- `Makefile` diff --git a/.tasks/done/TT-017-integration-test.md b/.tasks/done/TT-017-integration-test.md new file mode 100644 index 0000000..91958bd --- /dev/null +++ b/.tasks/done/TT-017-integration-test.md @@ -0,0 +1,48 @@ +# TT-017 — Integration-Test Makefile + Go-Binary + +**Type:** Task +**Priority:** Medium +**Labels:** testing, tooling +**Depends on:** TT-014, TT-015, TT-016 +**Blocks:** — + +--- + +## Summary +Verifiziere alle Makefile-Targets und das Go-Binary manuell durch atomare +Smoke-Tests. Kein automatisiertes Test-Framework — Ausgabe wird visuell geprüft. + +## Test-Schritte + +### Go-Binary +- [ ] `cd scripts/serve && go build -o serve .` → kein Fehler +- [ ] `./scripts/serve/serve --help` → Flags werden angezeigt +- [ ] `./scripts/serve/serve --dir build/apk --port 9999` ohne APK → Fehlermeldung klar + +### Retention-Script +- [ ] `bash scripts/apk_retention.sh --dir /tmp/test_apk` mit simulierten APK-Dummy-Dateien (touch) +- [ ] 20 APKs 0.1.x → nach Lauf: 15 verbleiben +- [ ] 5 APKs 0.1.x + 3 APKs 0.2.x → 0.1.x: 1 verbleibt (Release-Marker), 0.2.x: alle 3 + +### Makefile Dev-Targets +- [ ] `make help` → Ausgabe ohne Fehler +- [ ] `make install` → `flutter pub get` erfolgreich +- [ ] `make test` → 37 Tests grün +- [ ] `make analyze` → No issues +- [ ] `make format` → kein Fehler +- [ ] `make clean` → build/ und coverage/ entfernt + +### Makefile Build-Targets +- [ ] `make build_serve` → Binary erstellt +- [ ] `make build_web` → `build/web` vorhanden +- [ ] `make build_linux` → `build/linux/x64/release/bundle/timetrack` vorhanden +- [ ] `make db_schema` → Schema-Version + Tabellen sichtbar + +### serve_apk Auto-Build +- [ ] Binary löschen, dann `make serve_apk` → Binary wird transparent neu gebaut +- [ ] Server startet, QR-Code erscheint im Terminal + +## Acceptance Criteria +- [ ] Alle Smoke-Tests grün +- [ ] Kein Target bricht mit unerwartetem Fehler ab +- [ ] `make help` enthält alle implementierten Targets diff --git a/.tasks/done/TT-018-go-server-web-flag.md b/.tasks/done/TT-018-go-server-web-flag.md new file mode 100644 index 0000000..08c4bb7 --- /dev/null +++ b/.tasks/done/TT-018-go-server-web-flag.md @@ -0,0 +1,31 @@ +# TT-018 — Go-Server: --web Flag für Static-File-Serving + +**Type:** Task +**Priority:** High +**Labels:** backend, go, serve +**Depends on:** — +**Blocks:** TT-019, TT-020 + +--- + +## Summary + +Den bestehenden Go-Server (`scripts/serve/main.go`) um einen `--web` Flag erweitern. +Im Web-Modus wird das Verzeichnis `--dir` (Standard: `build/web/`) als Static-File-Server +ausgeliefert (`http.FileServer`). Die Flutter-Web-App läuft damit direkt im Browser des +Nutzers — kein Download-Button, kein APK-spezifisches UI. + +Im Terminal soll — identisch zum APK-Modus — die lokale IP-Adresse sowie ein QR-Code +ausgegeben werden, der auf die Serve-URL zeigt. + +## Acceptance Criteria + +- [ ] Neuer CLI-Flag `--web` (bool) vorhanden +- [ ] Im Web-Modus: `http.FileServer` auf `--dir` (Standard: `build/web/`) +- [ ] Im Web-Modus: Terminal-Ausgabe mit lokaler IP + QR-Code (via `qrencode`) +- [ ] APK-Modus (ohne `--web`) verhält sich unverändert +- [ ] `go build` kompiliert fehlerfrei + +## Files to create / modify + +- `scripts/serve/main.go` diff --git a/.tasks/done/TT-018-quick-access-fallback-projects.md b/.tasks/done/TT-018-quick-access-fallback-projects.md new file mode 100644 index 0000000..61b83ef --- /dev/null +++ b/.tasks/done/TT-018-quick-access-fallback-projects.md @@ -0,0 +1,22 @@ +# TT-018 — Quick-Access Grid: Fallback auf erste N Projekte + +**Type:** Task +**Priority:** Medium +**Labels:** timer, ux, quick-access +**Depends on:** TT-017 (Quick-Access Grid Implementierung) +**Blocks:** — + +--- + +## Summary +Wenn noch keine TimeEntries vorhanden sind (kein Score berechenbar), sollen im +Quick-Access-Grid die ersten N angelegten Projekte (sortiert nach `createdAt` aufsteigend) +angezeigt werden. Das verbessert die UX direkt nach dem ersten Anlegen von Projekten. + +## Acceptance Criteria +- [ ] Grid zeigt die ältesten N aktiven Projekte, wenn kein Projekt einen Score > 0 hat +- [ ] Sobald TimeEntries existieren, greift der normale Score-Algorithmus +- [ ] N entspricht dem konfigurierten `quickAccessCount`-Wert + +## Files to modify +- `lib/features/timer/domain/frequent_projects_provider.dart` diff --git a/.tasks/done/TT-019-makefile-serve-web.md b/.tasks/done/TT-019-makefile-serve-web.md new file mode 100644 index 0000000..90a88c2 --- /dev/null +++ b/.tasks/done/TT-019-makefile-serve-web.md @@ -0,0 +1,32 @@ +# TT-019 — Makefile: serve_web Target + WEB_PORT Variable + +**Type:** Task +**Priority:** High +**Labels:** makefile, devops, serve +**Depends on:** TT-018 +**Blocks:** TT-020 + +--- + +## Summary + +Im Makefile ein neues `serve_web` Target anlegen, das den Go-Server im Web-Modus startet. +Außerdem eine eigene `WEB_PORT`-Variable (Standard: `8080`) einführen, damit APK-Server +(Port `8888`) und Web-Server gleichzeitig betrieben werden können. + +Das `$(SERVE_BIN)`-Target soll als dateibasiertes Makefile-Target umgebaut werden, sodass +das Go-Binary nur dann neu kompiliert wird, wenn `scripts/serve/main.go` neuer ist als das +Binary. + +## Acceptance Criteria + +- [ ] Variable `WEB_PORT ?= 8080` im Makefile vorhanden +- [ ] `$(SERVE_BIN): scripts/serve/main.go` als dateibasiertes Target (Timestamp-Check) +- [ ] `serve_apk` und `serve_web` hängen von `$(SERVE_BIN)` ab +- [ ] `serve_web` ruft `$(SERVE_BIN) --dir build/web --port $(WEB_PORT) --web` auf +- [ ] `make serve_web` startet den Server korrekt +- [ ] `make serve_web WEB_PORT=9090` überschreibt den Port + +## Files to create / modify + +- `Makefile` diff --git a/.tasks/done/TT-019-quick-access-grid-fill.md b/.tasks/done/TT-019-quick-access-grid-fill.md new file mode 100644 index 0000000..6bb3b10 --- /dev/null +++ b/.tasks/done/TT-019-quick-access-grid-fill.md @@ -0,0 +1,25 @@ +# TT-019 — Quick-Access Grid: Auffüllen mit ältesten Projekten + +**Type:** Task +**Priority:** Medium +**Labels:** timer, ux, quick-access +**Depends on:** TT-018 +**Blocks:** — + +--- + +## Summary +Das Quick-Access-Grid soll immer bis zu N Slots füllen. Gescorte Projekte stehen +links (höchster Score zuerst). Verbleibende Slots werden mit den ältesten aktiven +Projekten aufgefüllt, die noch nicht im scored-Set sind. Erst wenn es mehr als N +gescorte Projekte gibt, werden ausschließlich scored-Projekte angezeigt. + +## Acceptance Criteria +- [ ] Gescorte Projekte erscheinen zuerst (links), sortiert nach Score +- [ ] Restliche Slots werden mit ältesten Projekten (nach createdAt) aufgefüllt +- [ ] Kein Projekt erscheint doppelt +- [ ] Grid zeigt maximal N Projekte (N = quickAccessCount) +- [ ] Gibt es >= N gescorte Projekte, werden nur gescorte angezeigt + +## Files to modify +- `lib/features/timer/domain/frequent_projects_provider.dart` diff --git a/.tasks/done/TT-020-fix-rangerror-scored-sort.md b/.tasks/done/TT-020-fix-rangerror-scored-sort.md new file mode 100644 index 0000000..dc6ba38 --- /dev/null +++ b/.tasks/done/TT-020-fix-rangerror-scored-sort.md @@ -0,0 +1,36 @@ +# TT-020 — Bug: RangeError in frequentProjects beim Sortieren + +**Type:** Bug +**Priority:** Highest +**Labels:** timer, quick-access, crash +**Depends on:** TT-019 +**Blocks:** — + +--- + +## Summary +`scoreOf` nutzt `scored.indexOf(p)` um den zugehörigen Index in `ages[]` zu ermitteln. +Während `List.sort()` die Liste umsortiert, liefert `indexOf` falsche oder `-1`-Indizes, +was zu einem `RangeError (length): Invalid value: Not in inclusive range 0..2: -1` führt. + +## Root Cause +```dart +scored.sort((a, b) { + final ia = scored.indexOf(a); // ← indexOf auf der Liste die gerade sortiert wird + final ib = scored.indexOf(b); + ... +}); +``` +`indexOf` sucht linear in der aktuell (halb-)sortierten Liste — der gefundene Index +stimmt nicht mehr mit dem ursprünglichen `ages`-Index überein. + +## Fix +Score vorab in einer `Map` (projectId → score) berechnen, +dann beim Sortieren direkt per ID nachschlagen. + +## Acceptance Criteria +- [ ] Kein RangeError mehr beim Wechsel zwischen gescorten/ungescorten Projekten +- [ ] Sortierung bleibt korrekt (höchster Score zuerst) + +## Files to modify +- `lib/features/timer/domain/frequent_projects_provider.dart` diff --git a/.tasks/done/TT-020-makefile-build-and-serve.md b/.tasks/done/TT-020-makefile-build-and-serve.md new file mode 100644 index 0000000..8e7b0a8 --- /dev/null +++ b/.tasks/done/TT-020-makefile-build-and-serve.md @@ -0,0 +1,31 @@ +# TT-020 — Makefile: build_and_serve_apk + build_and_serve_web Targets + +**Type:** Task +**Priority:** High +**Labels:** makefile, devops +**Depends on:** TT-018, TT-019 +**Blocks:** — + +--- + +## Summary + +Zwei neue Kombinations-Targets im Makefile anlegen, die Build und Serve in einem Schritt +zusammenfassen: + +- `build_and_serve_apk`: baut das Android-APK und startet anschließend den APK-Server +- `build_and_serve_web`: baut die Flutter-Web-App und startet anschließend den Web-Server + +Beide Targets sollen im `help`-Target dokumentiert sein. + +## Acceptance Criteria + +- [ ] `build_and_serve_apk` ruft `build_android` dann `serve_apk` auf +- [ ] `build_and_serve_web` ruft `build_web` dann `serve_web` auf +- [ ] Beide Targets in der `help`-Ausgabe sichtbar +- [ ] `make build_and_serve_apk` läuft durch (APK gebaut + Server gestartet) +- [ ] `make build_and_serve_web` läuft durch (Web gebaut + Server gestartet) + +## Files to create / modify + +- `Makefile` diff --git a/AGENT.md b/AGENT.md new file mode 100644 index 0000000..db6655a --- /dev/null +++ b/AGENT.md @@ -0,0 +1,123 @@ +# AGENT.md — Timetrack + +## Project Overview +Flutter time-tracking app for capturing and analysing working hours. +Targets: Android, iOS, Web. Data is stored locally (offline-first, no backend). + +## Stack +| Concern | Choice | +|----------------|-------------------------------| +| Framework | Flutter 3.41 / Dart 3.11 | +| State | Riverpod (riverpod_annotation)| +| Database | Drift + SQLite | +| Navigation | go_router (StatefulShellRoute)| +| Charts | fl_chart | +| Models | freezed + json_serializable | +| Export | pdf + csv + share_plus | +| i18n | flutter_localizations (ARB) | +| Tests | flutter_test + mocktail | + +## Architecture — Feature-first +``` +lib/ + main.dart # ProviderScope → App + app.dart # MaterialApp.router, theme, locales + core/ + database/ # Drift AppDatabase, DAOs + router/ # GoRouter provider (app_router.dart) + theme/ # AppTheme.light / AppTheme.dark + l10n/ # ARB files + AppLocalizations + features/ + timer/ # Active timer — start/stop, project select + entries/ # TimeEntry CRUD + manual input + projects/ # Project CRUD (name, color, description) + reports/ # Day/week/month views + fl_chart + settings/ # Theme toggle, language, export +``` +Each feature follows: `data/` → `domain/` → `presentation/` + +## Data Models + +### Project +```dart +int id, String name, int colorValue, String? description, +DateTime? archivedAt +``` + +### TimeEntry +```dart +int id, int projectId, DateTime startTime, DateTime? endTime, +Duration? duration, String? note, List tags +``` + +### Tag +```dart +int id, String name +``` +Junction table `time_entry_tags` links entries ↔ tags. +Full schema: see `.ai/database.md` + +## Riverpod Conventions +- Use `@riverpod` annotation (code-gen); run `dart run build_runner watch` +- Providers live in the feature's `data/` or `domain/` layer +- Never put providers in `presentation/` widgets directly — import from domain/data +- AsyncNotifier for async state, Notifier for sync state + +## Navigation +- `AppRoutes` constants in `lib/core/router/app_router.dart` +- Bottom nav: `/timer` | `/entries` | `/projects` | `/reports` | `/settings` +- Deep links use GoRouter sub-routes inside each branch + +## Coding Conventions +- **Language in code**: English (variables, comments, docs) +- **UI strings**: ARB files only — never hardcode display text +- **Models**: always `freezed` + `copyWith`; no mutable model classes +- **Async**: always `await`; no fire-and-forget without `unawaited()` annotation +- **Imports**: always `package:` imports, no relative `../` imports +- **Formatting**: `dart format` enforced; trailing commas required +- **Single quotes** throughout + +## Testing +See `.ai/testing.md` for full conventions. +- Unit tests for all repository methods and domain logic +- Widget tests for each screen (golden tests optional) +- Use `mocktail` for mocking repositories in widget tests +- Test files mirror `lib/` structure under `test/` + +## .ai/ Context Files +| File | Content | +|--------------------------|------------------------------------------| +| `.ai/database.md` | Full Drift schema, migration strategy | +| `.ai/architecture.md` | ADRs, dependency decisions | +| `.ai/testing.md` | Test conventions, patterns, examples | +| `.ai/tickets.md` | Ticket workflow, template, agent rules | +| `.ai/features/timer.md` | Timer state-machine, background rules | +| `.ai/features/reports.md`| Report queries, chart data format | +| `.ai/features/export.md` | CSV/PDF/JSON export logic | + +## Ticket Workflow +Every step in an implementation plan becomes a ticket under `.tasks/`. +Full rules, the ticket template, and the agent workflow are in `.ai/tickets.md`. + +**Short version:** +1. Break implementation plan into tickets → create in `.tasks/todo/` +2. Move one ticket to `.tasks/processing/` before starting work +3. Move to `.tasks/done/` when complete, then pick the next ticket +4. Update the overview table in `.tasks/README.md` for every new ticket + +## Interacting with the User +When any decision, clarification, or preference is needed — no matter how small — +**always use the `question` tool** rather than asking in plain text. +This applies to architecture choices, naming, scope, priority, and ambiguous requirements. +Only skip the question tool when the answer can be unambiguously derived from the codebase or existing documentation. + +## Do / Don't +| Do | Don't | +|-------------------------------------------------|----------------------------------------------| +| Use `ConsumerWidget` / `ConsumerStatefulWidget` | Don't use `StatefulWidget` + manual setState | +| Keep screens thin — delegate to providers | Don't put business logic in widgets | +| Use `drift` DAOs for all DB access | Don't use raw SQL strings outside DAOs | +| Write tests alongside new features | Don't commit untested repository methods | +| Export via `share_plus` share sheet | Don't write files to arbitrary paths | +| Use `freezed` for all domain models | Don't use plain Dart classes for models | +| Single active timer — stop before starting new | Don't allow concurrent timer instances | diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ae6c9bc --- /dev/null +++ b/Makefile @@ -0,0 +1,289 @@ +# Timetrack — Build & Run Commands +# Flutter cross-platform build automation + +.PHONY: help install generate test test_coverage analyze format clean \ + build_serve build_android build_aab build_linux build_web build_all \ + install_android deploy_android serve_apk serve_web \ + build_and_serve_apk build_and_serve_web \ + db_schema + +# ── Toolchain paths ──────────────────────────────────────────────────────────── +FLUTTER := $(HOME)/flutter/3.41.7/bin/flutter +JAVA_HOME ?= $(HOME)/java/17.0.13+11 +ANDROID_HOME ?= $(HOME)/Android/sdk +ADB := $(ANDROID_HOME)/platform-tools/adb + +# ── Versionierung ────────────────────────────────────────────────────────────── +# Werte werden aus der VERSION-Datei gelesen und nach jedem Build automatisch +# hochgezählt. Manuell überschreibbar: +# make build_android BUILD_VERSION=0.2.0 BUILD_NUMBER=5 +# +# TODO: Sobald das Projekt in Git liegt, BUILD_NUMBER durch git-Commits ersetzen: +# BUILD_NUMBER = $(shell git rev-list --count HEAD) +VERSION_FILE := VERSION +BUILD_VERSION ?= $(shell grep '^VERSION=' $(VERSION_FILE) | cut -d= -f2) +BUILD_NUMBER ?= $(shell grep '^BUILD_NUMBER=' $(VERSION_FILE) | cut -d= -f2) + +# ── APK-Pfade ───────────────────────────────────────────────────────────────── +APK_NAME := timetrack-$(BUILD_VERSION)+$(BUILD_NUMBER).apk +APK_DIR := build/apk +APK := $(APK_DIR)/$(APK_NAME) +APK_LATEST := $(APK_DIR)/timetrack.apk + +# ── Serve-Binary ────────────────────────────────────────────────────────────── +SERVE_BIN := scripts/serve/serve +PORT ?= 8888 +WEB_PORT ?= 8080 + +# ── Default target ───────────────────────────────────────────────────────────── +help: + @echo "Timetrack — Build Commands" + @echo "==========================" + @echo "" + @echo "Dev-Targets:" + @echo " make install - flutter pub get" + @echo " make generate - build_runner (Codegenerierung)" + @echo " make test - Flutter Tests ausführen" + @echo " make test_coverage - Tests mit Coverage-Report" + @echo " make analyze - flutter analyze" + @echo " make format - Dart-Code formatieren" + @echo " make clean - Build-Artefakte löschen" + @echo "" + @echo "Build-Targets:" + @echo " make build_serve - Go APK-Server kompilieren" + @echo " make build_android - Android APK (release)" + @echo " make build_aab - Android App Bundle (Play Store)" + @echo " make build_linux - Linux Desktop-App" + @echo " make build_web - Web-App" + @echo " make build_all - APK + Linux + Web" + @echo "" + @echo "Deploy-Targets:" + @echo " make install_android - APK via ADB auf Gerät installieren" + @echo " make deploy_android - build_android + install_android" + @echo " make serve_apk - APK per WLAN bereitstellen + QR-Code" + @echo " make serve_web - Web-App per WLAN bereitstellen + QR-Code" + @echo " make build_and_serve_apk - build_android + serve_apk" + @echo " make build_and_serve_web - build_web + serve_web" + @echo "" + @echo "Info-Targets:" + @echo " make db_schema - Drift-Schema-Version und Tabellen anzeigen" + @echo "" + @echo "Variablen (Beispiel):" + @echo " BUILD_VERSION=0.2.0 BUILD_NUMBER=5 make build_android" + @echo " PORT=9090 make serve_apk" + @echo " WEB_PORT=9091 make serve_web" + @echo " DEVICE=emulator-5554 make install_android" + +# ── Dev-Targets ─────────────────────────────────────────────────────────────── + +install: + @echo "Installing dependencies..." + $(FLUTTER) pub get + @echo "Done." + +generate: + @echo "Running code generation..." + $(FLUTTER) pub run build_runner build --delete-conflicting-outputs + @echo "Code generation complete." + +test: + @echo "Running tests..." + $(FLUTTER) test + @echo "Tests complete." + +test_coverage: + @echo "Running tests with coverage..." + $(FLUTTER) test --coverage + @if command -v genhtml > /dev/null 2>&1; then \ + genhtml coverage/lcov.info -o coverage/html --quiet; \ + echo "Coverage report: coverage/html/index.html"; \ + else \ + echo "Hinweis: genhtml nicht gefunden. lcov installieren für HTML-Report:"; \ + echo " sudo apt install lcov"; \ + echo "Rohes lcov-Ergebnis: coverage/lcov.info"; \ + fi + +analyze: + @echo "Running static analysis..." + $(FLUTTER) analyze + @echo "Analysis complete." + +format: + @echo "Formatting code..." + $(FLUTTER) pub run dart_style:format --fix lib/ test/ + @echo "Format complete." + +clean: + @echo "Cleaning build artifacts..." + $(FLUTTER) clean + rm -rf coverage/ + @echo "Clean complete." + +# ── Build: Go Server ────────────────────────────────────────────────────────── + +build_serve: $(SERVE_BIN) + +# Auto-Build: Binary wird nur neu gebaut wenn main.go neuer als Binary ist +$(SERVE_BIN): scripts/serve/main.go + @echo "Building serve binary..." + @command -v go > /dev/null 2>&1 || \ + (echo "FEHLER: go nicht gefunden. Bitte installieren: https://go.dev/dl/" && exit 1) + cd scripts/serve && go build -o serve . + @echo "Server binary: scripts/serve/serve" + +# ── Build: Android APK ──────────────────────────────────────────────────────── + +build_android: + @echo "Building Timetrack Android APK..." + @echo " Version : $(BUILD_VERSION)+$(BUILD_NUMBER)" + @echo "" + @# Voraussetzungen prüfen + @test -d "$(JAVA_HOME)" || \ + (echo "FEHLER: JAVA_HOME='$(JAVA_HOME)' nicht gefunden." && \ + echo " Überschreiben: JAVA_HOME=/pfad/zu/jdk make build_android" && exit 1) + @test -d "$(ANDROID_HOME)" || \ + (echo "FEHLER: ANDROID_HOME='$(ANDROID_HOME)' nicht gefunden." && \ + echo " Überschreiben: ANDROID_HOME=/pfad/zum/sdk make build_android" && exit 1) + @# Flutter APK bauen + JAVA_HOME="$(JAVA_HOME)" ANDROID_HOME="$(ANDROID_HOME)" \ + $(FLUTTER) build apk --release \ + --build-name=$(BUILD_VERSION) \ + --build-number=$(BUILD_NUMBER) + @# APK in build/apk/ ablegen + @mkdir -p $(APK_DIR) + cp build/app/outputs/flutter-apk/app-release.apk $(APK) + @# SHA256-Prüfsumme + sha256sum $(APK) | tee $(APK).sha256 + @# Symlink auf neueste Version aktualisieren + @cd $(APK_DIR) && ln -sf $(APK_NAME) timetrack.apk + @# Build-Number in VERSION-Datei hochzählen + @next=$$(( $(BUILD_NUMBER) + 1 )); \ + sed -i "s/^BUILD_NUMBER=.*/BUILD_NUMBER=$$next/" $(VERSION_FILE); \ + echo " Nächste Build-Number: $$next (in $(VERSION_FILE) gespeichert)" + @# Retention-Bereinigung + @bash scripts/apk_retention.sh --dir $(APK_DIR) --keep 15 + @echo "" + @echo "APK fertig:" + @find $(APK_DIR) -name "$(APK_NAME)" -type f + @echo "" + @echo "Auf Gerät installieren: make install_android" + @echo "WLAN-Download starten: make serve_apk" + +# ── Build: Android App Bundle (Play Store) ─────────────────────────────────── + +build_aab: + @echo "Building Timetrack Android App Bundle (AAB)..." + @echo " Version : $(BUILD_VERSION)+$(BUILD_NUMBER)" + @test -d "$(JAVA_HOME)" || \ + (echo "FEHLER: JAVA_HOME='$(JAVA_HOME)' nicht gefunden." && exit 1) + @test -d "$(ANDROID_HOME)" || \ + (echo "FEHLER: ANDROID_HOME='$(ANDROID_HOME)' nicht gefunden." && exit 1) + JAVA_HOME="$(JAVA_HOME)" ANDROID_HOME="$(ANDROID_HOME)" \ + $(FLUTTER) build appbundle --release \ + --build-name=$(BUILD_VERSION) \ + --build-number=$(BUILD_NUMBER) + @echo "" + @echo "AAB fertig:" + @find build/app/outputs/bundle -name "*.aab" -type f + @echo "" + @echo "Nächste Schritte:" + @echo " 1. AAB signieren (Keystore konfigurieren)" + @echo " 2. In Play Console hochladen: Testen → Internes Testen → Upload" + +# ── Build: Linux Desktop ────────────────────────────────────────────────────── + +build_linux: + @which ld.lld > /dev/null 2>&1 || \ + (echo "" && \ + echo "FEHLER: ld.lld (LLVM-Linker) nicht gefunden." && \ + echo "Bitte installieren mit:" && \ + echo " sudo apt install lld" && \ + echo "" && exit 1) + @echo "Building Timetrack Linux app..." + $(FLUTTER) build linux --release \ + --build-name=$(BUILD_VERSION) \ + --build-number=$(BUILD_NUMBER) + @echo "" + @echo "Linux build fertig:" + @find build/linux -maxdepth 4 -name "timetrack" -type f 2>/dev/null || \ + find build/linux -maxdepth 4 -name "*.elf" -type f 2>/dev/null || true + @echo "" + @echo "Starten: ./build/linux/x64/release/bundle/timetrack" + +# ── Build: Web ──────────────────────────────────────────────────────────────── + +build_web: + @echo "Building Timetrack Web app..." + $(FLUTTER) build web --release + @echo "" + @echo "Web build fertig: build/web/" + @echo "WLAN-Zugriff starten: make serve_web" + +# ── Build: Alle Plattformen ─────────────────────────────────────────────────── + +build_all: build_android build_linux build_web + @echo "" + @echo "Alle Builds abgeschlossen:" + @echo " APK : $(APK)" + @echo " Linux : build/linux/x64/release/bundle/timetrack" + @echo " Web : build/web/" + +# ── Deploy: Android ─────────────────────────────────────────────────────────── + +install_android: + @echo "Suche verbundene Android-Geräte..." + @$(ADB) devices | grep -v "List of" | grep "device$$" > /dev/null || \ + (echo "FEHLER: Kein Gerät gefunden. USB-Kabel und USB-Debugging prüfen." && exit 1) + @test -f "$(APK_LATEST)" || test -f "$(APK)" || \ + (echo "FEHLER: APK nicht gefunden. Zuerst 'make build_android' ausführen." && exit 1) + @APK_TO_INSTALL="$(APK_LATEST)"; \ + test -f "$$APK_TO_INSTALL" || APK_TO_INSTALL="$(APK)"; \ + echo "Installiere $$APK_TO_INSTALL..."; \ + if [ -n "$(DEVICE)" ]; then \ + $(ADB) -s $(DEVICE) install -r "$$APK_TO_INSTALL"; \ + else \ + $(ADB) install -r "$$APK_TO_INSTALL"; \ + fi + @echo "" + @echo "Installation abgeschlossen." + +deploy_android: build_android install_android + +# ── Serve + Build: Kombinierte Targets ─────────────────────────────────────── + +build_and_serve_apk: build_android serve_apk + +build_and_serve_web: build_web serve_web + +# ── Serve: WLAN APK-Download ────────────────────────────────────────────────── + +serve_apk: $(SERVE_BIN) + @test -d "$(APK_DIR)" || \ + (echo "FEHLER: $(APK_DIR)/ nicht gefunden. Zuerst 'make build_android' ausführen." && exit 1) + $(SERVE_BIN) --dir $(APK_DIR) --port $(PORT) + +# ── Serve: WLAN Web ─────────────────────────────────────────────────────────── + +serve_web: $(SERVE_BIN) + @test -d "build/web" || \ + (echo "FEHLER: build/web/ nicht gefunden. Zuerst 'make build_web' ausführen." && exit 1) + $(SERVE_BIN) --web --dir build/web --port $(WEB_PORT) + +# ── Info: Drift-Schema ──────────────────────────────────────────────────────── + +db_schema: + @echo "Timetrack — Drift Schema" + @echo "========================" + @echo "" + @echo "Schema-Version:" + @grep -h "schemaVersion" lib/core/database/app_database.dart 2>/dev/null | \ + grep -v "//" | sed 's/^[[:space:]]*/ /' + @echo "" + @echo "Tabellen:" + @grep -h "class.*extends Table" lib/core/database/tables/*.dart 2>/dev/null | \ + sed 's/class \([A-Za-z]*\) extends Table.*/ \1/' | sort + @echo "" + @echo "DAOs:" + @grep -h "class.*Dao" lib/core/database/daos/*.dart 2>/dev/null | \ + grep "class.*DatabaseAccessor" | \ + sed 's/class \([A-Za-z]*\) extends.*/ \1/' | sort diff --git a/Makefile.example b/Makefile.example new file mode 100644 index 0000000..075393f --- /dev/null +++ b/Makefile.example @@ -0,0 +1,322 @@ +# Gullrune - Build & Run Commands +# Cross-platform build automation for Flet app + +.PHONY: help run install test clean build_win build_android build_aab install_android deploy_android serve_apk build_ios build_linux build_all + +# ── Toolchain paths (auto-detected by flet, override here if needed) ────────── +JAVA_HOME ?= $(HOME)/java/17.0.13+11 +ANDROID_HOME ?= $(HOME)/Android/sdk + +# ── Versionierung ────────────────────────────────────────────────────────────── +# Überschreibbar: make build_aab BUILD_VERSION=1.1.0 BUILD_NUMBER=2 +BUILD_VERSION ?= 1.0.0 +BUILD_NUMBER ?= 1 + +# Default target +help: + @echo "Gullrune - Build Commands" + @echo "=========================" + @echo "" + @echo "Available targets:" + @echo " make run - Run the app in development mode" + @echo " make install - Install all dependencies" + @echo " make test - Run security tests" + @echo " make clean - Clean build artifacts" + @echo "" + @echo "Build targets:" + @echo " make build_win - Build Windows desktop app" + @echo " make build_android - Build Android APK" + @echo " make install_android - Install APK auf verbundenem Android-Gerät (USB)" + @echo " make deploy_android - Build + Install in einem Schritt" + @echo " make serve_apk - APK per lokalem HTTP-Server im WLAN bereitstellen" + @echo " make build_ios - Build iOS IPA (macOS only)" + @echo " make build_linux - Build Linux app" + @echo " make build_all - Build for all platforms" + @echo "" + @echo "Migration targets:" + @echo " make migrate - Run Alembic migrations" + @echo " make migrate_create - Create new migration" + +# Run the app in development mode +run: + @echo "Starting Gullrune..." + uv run python main.py + +# Install dependencies +install: + @echo "Installing dependencies..." + pip install -r requirements.txt + @echo "Done! All dependencies installed." + +# Run tests +test: + @echo "Running security tests..." + pytest tests/ -v + @echo "Tests completed." + +# Run tests with coverage +test_coverage: + @echo "Running tests with coverage..." + pytest tests/ --cov=src --cov-report=html + @echo "Coverage report generated in htmlcov/" + +# Clean build artifacts +clean: + @echo "Cleaning build artifacts..." + rm -rf build/ + rm -rf dist/ + rm -rf *.egg-info + find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true + find . -type f -name "*.pyc" -delete 2>/dev/null || true + @echo "Clean complete." + +# Build Windows desktop app +build_win: + @echo "Building Windows desktop application..." + @echo "This will create a standalone .exe in build/windows/" + flet build windows --verbose + @echo "Windows build complete! Check build/windows/" + +# Build Android APK +# ───────────────────────────────────────────────────────────────────────────── +# Voraussetzungen (werden beim ersten Build automatisch installiert falls nötig): +# - JDK 17 → $(HOME)/java/17.0.13+11 (auto-install by flet) +# - Android SDK 36 + Build-Tools 28.0.3 (auto-install by flet) +# +# Bekannte Fallstricke: +# - flet[all] in pyproject.toml zieht watchdog rein → kein Android-Wheel. +# Lösung: [project].dependencies nutzt nur "flet==...", flet[all] liegt +# unter [project.optional-dependencies].dev +# - Dart SDK-Cache nach Flutter-Update leeren: +# rm -rf $(HOME)/flutter/3.41.4/bin/cache +# +# Ausgabe: build/apk/gullrune.apk +# ───────────────────────────────────────────────────────────────────────────── +build_android: + @echo "Building Gullrune Android APK..." + @echo "Erster Build: 10-20 Minuten (Gradle + SDK-Download)" + @echo "Folge-Builds: ~3-5 Minuten (Cache)" + uv run python3 scripts/gen_version.py --version $(BUILD_VERSION) --build $(BUILD_NUMBER) + JAVA_HOME="$(JAVA_HOME)" ANDROID_HOME="$(ANDROID_HOME)" \ + uv run flet build apk \ + --project gullrune \ + --product "Gullrune" \ + --org com.keldamar \ + --bundle-id com.keldamar.gullrune \ + --build-version $(BUILD_VERSION) \ + --build-number $(BUILD_NUMBER) \ + --splash-color "#1A2A38" \ + --splash-dark-color "#1A2A38" \ + --android-adaptive-icon-background "#2A6F9E" \ + --compile-app \ + --compile-packages \ + --exclude tests .git .venv user_data + @echo "" + @echo "APK fertig:" + @find build/apk -name "*.apk" -type f + @echo "" + @sha256sum build/apk/gullrune.apk | tee build/apk/gullrune.apk.sha256 + @echo "" + @echo "Auf Gerät installieren: adb install build/apk/gullrune.apk" + @echo "WLAN-Download starten: make serve_apk" + +# Build Android App Bundle (AAB) — für den Google Play Store +# ───────────────────────────────────────────────────────────────────────────── +# AAB ist das von Google empfohlene Format für den Play Store. +# Google Play optimiert die Auslieferung pro Gerät automatisch. +# +# Vor dem Upload: AAB mit Upload-Keystore signieren (siehe Signing-Anleitung). +# Ausgabe: build/aab/gullrune.aab +# ───────────────────────────────────────────────────────────────────────────── +build_aab: + @echo "Building Gullrune Android App Bundle (AAB) für Play Store..." + uv run python3 scripts/gen_version.py --version $(BUILD_VERSION) --build $(BUILD_NUMBER) + JAVA_HOME="$(JAVA_HOME)" ANDROID_HOME="$(ANDROID_HOME)" \ + uv run flet build aab \ + --project gullrune \ + --product "Gullrune" \ + --org com.keldamar \ + --bundle-id com.keldamar.gullrune \ + --build-version $(BUILD_VERSION) \ + --build-number $(BUILD_NUMBER) \ + --splash-color "#1A2A38" \ + --splash-dark-color "#1A2A38" \ + --android-adaptive-icon-background "#2A6F9E" \ + --compile-app \ + --compile-packages \ + --exclude tests .git .venv user_data + @echo "" + @echo "AAB fertig:" + @find build/aab -name "*.aab" -type f + @echo "" + @sha256sum build/aab/gullrune.aab | tee build/aab/gullrune.aab.sha256 + @echo "" + @echo "Nächste Schritte:" + @echo " 1. AAB mit Upload-Keystore signieren (falls noch nicht via flet signing konfiguriert)" + @echo " 2. In Play Console hochladen: Test and release → Internal testing → Upload" + +# Install Android APK auf verbundenem Gerät +# ───────────────────────────────────────────────────────────────────────────── +# Voraussetzungen: +# - Android-Gerät per USB verbunden, USB-Debugging aktiviert +# - APK muss bereits gebaut sein (make build_android) +# +# Mehrere Geräte: DEVICE= make install_android +# Serials anzeigen mit: adb devices +# ───────────────────────────────────────────────────────────────────────────── +APK := build/apk/gullrune.apk +ADB := $(ANDROID_HOME)/platform-tools/adb + +install_android: + @echo "Suche verbundene Android-Geräte..." + @$(ADB) devices | grep -v "List of" | grep "device$$" > /dev/null || \ + (echo "FEHLER: Kein Gerät gefunden. USB-Kabel und USB-Debugging prüfen." && exit 1) + @test -f "$(APK)" || \ + (echo "FEHLER: $(APK) nicht gefunden. Zuerst 'make build_android' ausführen." && exit 1) + @echo "Installiere $(APK)..." + $(if $(DEVICE), \ + $(ADB) -s $(DEVICE) install -r "$(APK)", \ + $(ADB) install -r "$(APK)" \ + ) + @echo "" + @echo "Installation abgeschlossen. App auf Gerät starten:" + $(if $(DEVICE), \ + $(ADB) -s $(DEVICE) shell am start -n com.keldamar.gullrune/.MainActivity, \ + $(ADB) shell am start -n com.keldamar.gullrune/.MainActivity \ + ) + +# Shortcut: Build + Install in einem Schritt +deploy_android: build_android install_android + +# APK per lokalem HTTP-Server im WLAN bereitstellen +# ───────────────────────────────────────────────────────────────────────────── +# Das Handy muss im selben WLAN wie dieser Rechner sein. +# Ablauf: +# 1. QR-Code im Terminal scannen +# 2. Im Android-Browser die angezeigte URL öffnen +# 3. APK herunterladen und "Installieren" tippen +# 4. Einmalig: Einstellungen → Sicherheit → Browser als Installationsquelle erlauben +# +# Port überschreiben: PORT=9090 make serve_apk +# ───────────────────────────────────────────────────────────────────────────── +PORT ?= 8888 + +serve_apk: + @test -f "$(APK)" || \ + (echo "FEHLER: $(APK) nicht gefunden. Zuerst 'make build_android' ausführen." && exit 1) + uv run python3 scripts/serve_apk.py --dir build/apk --port $(PORT) + +serve_apk_debug: + @test -f "$(APK)" || \ + (echo "FEHLER: $(APK) nicht gefunden. Zuerst 'make build_android' ausführen." && exit 1) + uv run python3 scripts/serve_apk.py --dir build/apk --port $(PORT) --debug + +# Build iOS IPA (macOS only) +build_ios: + @echo "Building iOS IPA..." + @echo "Requirements: macOS with Xcode installed" + @echo "This may take 10-20 minutes on first build..." + flet build ipa --verbose + @echo "iOS IPA build complete! Check build/ipa/" + +# Build Linux desktop app +# ───────────────────────────────────────────────────────────────────────────── +# Voraussetzung: lld (LLVM-Linker) muss installiert sein. +# Falls nicht vorhanden: +# sudo apt install lld +# +# Das Target prüft dies automatisch und gibt einen klaren Hinweis. +# +# Ausgabe: build/linux/gullrune (ausführbare Datei + shared libs) +# ───────────────────────────────────────────────────────────────────────────── +build_linux: + @which ld.lld > /dev/null 2>&1 || \ + (echo "" && \ + echo "FEHLER: ld.lld (LLVM-Linker) nicht gefunden." && \ + echo "Bitte installieren mit:" && \ + echo " sudo apt install lld" && \ + echo "" && exit 1) + @echo "Building Gullrune Linux app..." + uv run flet build linux \ + --project gullrune \ + --product "Gullrune" \ + --build-version 1.0.0 \ + --build-number 1 \ + --compile-app \ + --compile-packages \ + --exclude tests .git .venv user_data + @echo "" + @echo "Linux build fertig:" + @find build/linux -maxdepth 2 -name "gullrune" -type f + @echo "" + @echo "Starten: ./build/linux/gullrune" + +# Build for all platforms (use with caution) +build_all: build_win build_linux + @echo "All platform builds complete!" + @echo "Note: Android and iOS builds skipped (use build_android/build_ios separately)" + +# Database migration targets +migrate: + @echo "Running database migrations..." + alembic upgrade head + @echo "Migrations complete." + +migrate_create: + @echo "Creating new migration..." + @read -p "Migration message: " message; \ + alembic revision --autogenerate -m "$$message" + @echo "Migration created in alembic/versions/" + +migrate_downgrade: + @echo "Rolling back last migration..." + alembic downgrade -1 + @echo "Rollback complete." + +# Relay server +# ───────────────────────────────────────────────────────────────────────────── +# Startet den Gullrune Relay-Server lokal (für Entwicklung / Test). +# Für Produktion: systemd-Service oder Docker auf einem VPS. +# +# Beispiel Docker: +# docker run -d -p 8765:8765 \ +# -v $(PWD)/server:/app \ +# python:3.12-slim \ +# sh -c "pip install websockets && python /app/relay.py" +# ───────────────────────────────────────────────────────────────────────────── +RELAY_PORT ?= 8765 + +relay: + @echo "Starting Gullrune relay server on ws://0.0.0.0:$(RELAY_PORT) ..." + uv run python server/relay.py --port $(RELAY_PORT) + +# Development helpers +dev: install run + +format: + @echo "Formatting code with black..." + black src/ tests/ main.py + @echo "Code formatted." + +lint: + @echo "Linting code..." + pylint src/ main.py || true + @echo "Lint complete." + +# Package for distribution +package_win: build_win + @echo "Creating Windows installer..." + @echo "TODO: Add NSIS or InnoSetup script" + +package_android: build_android + @echo "Android APK ready for distribution at:" + @find build/apk -name "*.apk" -type f + +# Security check +security_check: + @echo "Running security checks..." + @echo "Checking for .env in git..." + @git check-ignore .env && echo "✓ .env is ignored" || echo "✗ WARNING: .env not ignored!" + @echo "Checking database encryption..." + @grep -q "DB_ENCRYPTION_KEY" .env && echo "✓ Encryption key exists" || echo "✗ WARNING: No encryption key!" + @echo "Security check complete." diff --git a/README.md b/README.md new file mode 100644 index 0000000..1aa96b4 --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +# timetrack + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter) +- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/REPOMAP.md b/REPOMAP.md new file mode 100644 index 0000000..0829ed2 --- /dev/null +++ b/REPOMAP.md @@ -0,0 +1,86 @@ +# REPOMAP — Timetrack + +> Machine-readable overview of the repository. Every important file and +> directory with a one-line description. Update this file when adding new +> features or renaming paths. + +## Root +| Path | Description | +|--------------------------|--------------------------------------------------| +| `pubspec.yaml` | Dependencies and Flutter config | +| `analysis_options.yaml` | Dart linter rules | +| `AGENT.md` | AI agent context — project rules and conventions | +| `REPOMAP.md` | This file — repository map | + +## lib/ +| Path | Description | +|---------------------------------------------------|-----------------------------------------------------| +| `lib/main.dart` | Entry point — ProviderScope wraps App | +| `lib/app.dart` | MaterialApp.router — theme, locales, router | + +### lib/core/ +| Path | Description | +|---------------------------------------------------|-----------------------------------------------------| +| `lib/core/database/app_database.dart` | Drift AppDatabase — tables, DAOs, connection setup | +| `lib/core/router/app_router.dart` | GoRouter provider — routes, bottom nav shell | +| `lib/core/theme/app_theme.dart` | AppTheme.light / AppTheme.dark (Material 3) | +| `lib/core/l10n/` | ARB localisation files (en, de, …) | + +### lib/features/timer/ +| Path | Description | +|---------------------------------------------------|-----------------------------------------------------| +| `lib/features/timer/data/timer_repository.dart` | TimerRepository interface | +| `lib/features/timer/domain/` | TimerState (freezed), TimerNotifier (Riverpod) | +| `lib/features/timer/presentation/timer_screen.dart` | Timer UI — start/stop, project picker, elapsed | + +### lib/features/entries/ +| Path | Description | +|---------------------------------------------------|-----------------------------------------------------| +| `lib/features/entries/data/entries_repository.dart` | EntriesRepository — CRUD TimeEntry | +| `lib/features/entries/domain/` | TimeEntry model (freezed), EntriesNotifier | +| `lib/features/entries/presentation/entries_screen.dart` | List of time entries, manual add/edit | + +### lib/features/projects/ +| Path | Description | +|---------------------------------------------------|-----------------------------------------------------| +| `lib/features/projects/data/projects_repository.dart` | ProjectsRepository — CRUD Project | +| `lib/features/projects/domain/` | Project model (freezed), ProjectsNotifier | +| `lib/features/projects/presentation/projects_screen.dart` | Project list, create/edit/archive | + +### lib/features/reports/ +| Path | Description | +|---------------------------------------------------|-----------------------------------------------------| +| `lib/features/reports/data/reports_repository.dart` | Report queries — aggregation by day/week/month | +| `lib/features/reports/domain/` | ReportData model, ReportsNotifier | +| `lib/features/reports/presentation/reports_screen.dart` | Day/week/month charts (fl_chart) | + +### lib/features/settings/ +| Path | Description | +|---------------------------------------------------|-----------------------------------------------------| +| `lib/features/settings/presentation/settings_screen.dart` | Language, theme, export, about | + +## test/ +| Path | Description | +|---------------------------------------------------|-----------------------------------------------------| +| `test/features/timer/` | Unit + widget tests for timer feature | +| `test/features/entries/` | Unit + widget tests for entries feature | +| `test/features/projects/` | Unit + widget tests for projects feature | +| `test/features/reports/` | Unit + widget tests for reports feature | +| `test/core/` | Database migration tests, router tests | + +## .ai/ +| Path | Description | +|----------------------------|-------------------------------------------------------| +| `.ai/database.md` | Full Drift schema, DAO list, migration strategy | +| `.ai/architecture.md` | ADRs — why Riverpod, Drift, go_router, feature-first | +| `.ai/testing.md` | Test patterns, mocktail conventions, coverage goals | +| `.ai/features/timer.md` | Timer state machine, restore-on-launch logic | +| `.ai/features/reports.md` | Report queries, fl_chart data format, filters | +| `.ai/features/export.md` | CSV/PDF/JSON schemas, share_plus flow | + +## Generated Files (do not edit manually) +| Pattern | Generator | +|---------------------------|------------------| +| `**/*.g.dart` | build_runner | +| `**/*.freezed.dart` | freezed | +| `lib/core/router/app_router.g.dart` | riverpod_generator | diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..cce272d --- /dev/null +++ b/VERSION @@ -0,0 +1,2 @@ +VERSION=0.1.0 +BUILD_NUMBER=10 diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..f55430a --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,25 @@ +include: package:flutter_lints/flutter.yaml + +linter: + rules: + # Style + always_use_package_imports: true + prefer_single_quotes: true + require_trailing_commas: true + + # Safety + avoid_dynamic_calls: true + avoid_print: true + cancel_subscriptions: true + close_sinks: true + unawaited_futures: true + + # Riverpod / architecture + public_member_api_docs: false + +analyzer: + errors: + invalid_annotation_target: ignore # freezed generates these + exclude: + - "**/*.g.dart" + - "**/*.freezed.dart" diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/android/.gitignore @@ -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 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..6304a6b --- /dev/null +++ b/android/app/build.gradle.kts @@ -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.timetrack.timetrack" + 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.timetrack.timetrack" + // 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 = "../.." +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..4d6d585 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/timetrack/timetrack/MainActivity.kt b/android/app/src/main/kotlin/com/timetrack/timetrack/MainActivity.kt new file mode 100644 index 0000000..fb20546 --- /dev/null +++ b/android/app/src/main/kotlin/com/timetrack/timetrack/MainActivity.kt @@ -0,0 +1,5 @@ +package com.timetrack.timetrack + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/android/build.gradle.kts @@ -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("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..fbee1d8 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e4ef43f --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..ca7fe06 --- /dev/null +++ b/android/settings.gradle.kts @@ -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") diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/ios/.gitignore @@ -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 diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..391a902 --- /dev/null +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + + diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..972a52f --- /dev/null +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,620 @@ +// !$*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 */; }; + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.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 = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 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 = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 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 = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* 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 = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 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 */, + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* 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 */, + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift 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 = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* 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.timetrack.timetrack; + 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.timetrack.timetrack.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.timetrack.timetrack.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.timetrack.timetrack.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.timetrack.timetrack; + 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.timetrack.timetrack; + 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 */; +} diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..e3773d4 --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..c30b367 --- /dev/null +++ b/ios/Runner/AppDelegate.swift @@ -0,0 +1,16 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -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" + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..dc9ada4 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..7353c41 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..6ed2d93 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..4cd7b00 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..fe73094 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..321773c Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..502f463 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..e9f5fea Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..84ac32a Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..8953cba Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..0467bf1 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -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" + } +} diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -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. \ No newline at end of file diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist new file mode 100644 index 0000000..dbb1e53 --- /dev/null +++ b/ios/Runner/Info.plist @@ -0,0 +1,70 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Timetrack + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + timetrack + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/ios/Runner/SceneDelegate.swift b/ios/Runner/SceneDelegate.swift new file mode 100644 index 0000000..b9ce8ea --- /dev/null +++ b/ios/Runner/SceneDelegate.swift @@ -0,0 +1,6 @@ +import Flutter +import UIKit + +class SceneDelegate: FlutterSceneDelegate { + +} diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/ios/RunnerTests/RunnerTests.swift @@ -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. + } + +} diff --git a/l10n.yaml b/l10n.yaml new file mode 100644 index 0000000..8d3a847 --- /dev/null +++ b/l10n.yaml @@ -0,0 +1,4 @@ +arb-dir: lib/core/l10n +template-arb-file: app_en.arb +output-localization-file: app_localizations.dart +output-class: AppLocalizations diff --git a/lib/app.dart b/lib/app.dart new file mode 100644 index 0000000..026c1f5 --- /dev/null +++ b/lib/app.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; + +import 'package:timetrack/core/l10n/app_localizations.dart'; +import 'package:timetrack/core/router/app_router.dart'; +import 'package:timetrack/core/theme/app_theme.dart'; +import 'package:timetrack/features/settings/domain/settings_provider.dart'; + +class App extends ConsumerWidget { + const App({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final router = ref.watch(appRouterProvider); + final themeMode = ref.watch(themeModeNotifierProvider); + final locale = ref.watch(localeNotifierProvider); + + return MaterialApp.router( + title: 'Timetrack', + theme: AppTheme.light, + darkTheme: AppTheme.dark, + themeMode: themeMode, + locale: locale, + routerConfig: router, + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: const [ + Locale('en'), + Locale('de'), + ], + ); + } +} diff --git a/lib/core/database/app_database.dart b/lib/core/database/app_database.dart new file mode 100644 index 0000000..95bd769 --- /dev/null +++ b/lib/core/database/app_database.dart @@ -0,0 +1,42 @@ +import 'package:drift/drift.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:timetrack/core/database/connection/connection_stub.dart' + if (dart.library.io) 'package:timetrack/core/database/connection/connection_native.dart' + if (dart.library.html) 'package:timetrack/core/database/connection/connection_web.dart'; +import 'package:timetrack/core/database/tables/projects_table.dart'; +import 'package:timetrack/core/database/tables/time_entries_table.dart'; +import 'package:timetrack/core/database/tables/tags_table.dart'; +import 'package:timetrack/core/database/tables/time_entry_tags_table.dart'; +import 'package:timetrack/core/database/daos/projects_dao.dart'; +import 'package:timetrack/core/database/daos/time_entries_dao.dart'; +import 'package:timetrack/core/database/daos/tags_dao.dart'; + +part 'app_database.g.dart'; + +@DriftDatabase( + tables: [Projects, TimeEntries, Tags, TimeEntryTags], + daos: [ProjectsDao, TimeEntriesDao, TagsDao], +) +class AppDatabase extends _$AppDatabase { + AppDatabase([QueryExecutor? executor]) + : super(executor ?? openDatabaseConnection()); + + @override + int get schemaVersion => 1; + + @override + MigrationStrategy get migration => MigrationStrategy( + onCreate: (m) => m.createAll(), + onUpgrade: (m, from, to) async { + // Future migrations go here + }, + ); +} + +@Riverpod(keepAlive: true) +AppDatabase appDatabase(AppDatabaseRef ref) { + final db = AppDatabase(); + ref.onDispose(db.close); + return db; +} diff --git a/lib/core/database/app_database.g.dart b/lib/core/database/app_database.g.dart new file mode 100644 index 0000000..8603da1 --- /dev/null +++ b/lib/core/database/app_database.g.dart @@ -0,0 +1,2709 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'app_database.dart'; + +// ignore_for_file: type=lint +class $ProjectsTable extends Projects with TableInfo<$ProjectsTable, Project> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $ProjectsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), + ); + static const VerificationMeta _nameMeta = const VerificationMeta('name'); + @override + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + additionalChecks: GeneratedColumn.checkTextLength( + minTextLength: 1, + maxTextLength: 100, + ), + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways('UNIQUE'), + ); + static const VerificationMeta _colorValueMeta = const VerificationMeta( + 'colorValue', + ); + @override + late final GeneratedColumn colorValue = GeneratedColumn( + 'color_value', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _descriptionMeta = const VerificationMeta( + 'description', + ); + @override + late final GeneratedColumn description = GeneratedColumn( + 'description', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _archivedAtMeta = const VerificationMeta( + 'archivedAt', + ); + @override + late final GeneratedColumn archivedAt = GeneratedColumn( + 'archived_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _createdAtMeta = const VerificationMeta( + 'createdAt', + ); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: currentDateAndTime, + ); + @override + List get $columns => [ + id, + name, + colorValue, + description, + archivedAt, + createdAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'projects'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('name')) { + context.handle( + _nameMeta, + name.isAcceptableOrUnknown(data['name']!, _nameMeta), + ); + } else if (isInserting) { + context.missing(_nameMeta); + } + if (data.containsKey('color_value')) { + context.handle( + _colorValueMeta, + colorValue.isAcceptableOrUnknown(data['color_value']!, _colorValueMeta), + ); + } else if (isInserting) { + context.missing(_colorValueMeta); + } + if (data.containsKey('description')) { + context.handle( + _descriptionMeta, + description.isAcceptableOrUnknown( + data['description']!, + _descriptionMeta, + ), + ); + } + if (data.containsKey('archived_at')) { + context.handle( + _archivedAtMeta, + archivedAt.isAcceptableOrUnknown(data['archived_at']!, _archivedAtMeta), + ); + } + if (data.containsKey('created_at')) { + context.handle( + _createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), + ); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + Project map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return Project( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + colorValue: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}color_value'], + )!, + description: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}description'], + ), + archivedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}archived_at'], + ), + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + ); + } + + @override + $ProjectsTable createAlias(String alias) { + return $ProjectsTable(attachedDatabase, alias); + } +} + +class Project extends DataClass implements Insertable { + final int id; + final String name; + final int colorValue; + final String? description; + final DateTime? archivedAt; + final DateTime createdAt; + const Project({ + required this.id, + required this.name, + required this.colorValue, + this.description, + this.archivedAt, + required this.createdAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['name'] = Variable(name); + map['color_value'] = Variable(colorValue); + if (!nullToAbsent || description != null) { + map['description'] = Variable(description); + } + if (!nullToAbsent || archivedAt != null) { + map['archived_at'] = Variable(archivedAt); + } + map['created_at'] = Variable(createdAt); + return map; + } + + ProjectsCompanion toCompanion(bool nullToAbsent) { + return ProjectsCompanion( + id: Value(id), + name: Value(name), + colorValue: Value(colorValue), + description: description == null && nullToAbsent + ? const Value.absent() + : Value(description), + archivedAt: archivedAt == null && nullToAbsent + ? const Value.absent() + : Value(archivedAt), + createdAt: Value(createdAt), + ); + } + + factory Project.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return Project( + id: serializer.fromJson(json['id']), + name: serializer.fromJson(json['name']), + colorValue: serializer.fromJson(json['colorValue']), + description: serializer.fromJson(json['description']), + archivedAt: serializer.fromJson(json['archivedAt']), + createdAt: serializer.fromJson(json['createdAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'name': serializer.toJson(name), + 'colorValue': serializer.toJson(colorValue), + 'description': serializer.toJson(description), + 'archivedAt': serializer.toJson(archivedAt), + 'createdAt': serializer.toJson(createdAt), + }; + } + + Project copyWith({ + int? id, + String? name, + int? colorValue, + Value description = const Value.absent(), + Value archivedAt = const Value.absent(), + DateTime? createdAt, + }) => Project( + id: id ?? this.id, + name: name ?? this.name, + colorValue: colorValue ?? this.colorValue, + description: description.present ? description.value : this.description, + archivedAt: archivedAt.present ? archivedAt.value : this.archivedAt, + createdAt: createdAt ?? this.createdAt, + ); + Project copyWithCompanion(ProjectsCompanion data) { + return Project( + id: data.id.present ? data.id.value : this.id, + name: data.name.present ? data.name.value : this.name, + colorValue: data.colorValue.present + ? data.colorValue.value + : this.colorValue, + description: data.description.present + ? data.description.value + : this.description, + archivedAt: data.archivedAt.present + ? data.archivedAt.value + : this.archivedAt, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + ); + } + + @override + String toString() { + return (StringBuffer('Project(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('colorValue: $colorValue, ') + ..write('description: $description, ') + ..write('archivedAt: $archivedAt, ') + ..write('createdAt: $createdAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(id, name, colorValue, description, archivedAt, createdAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Project && + other.id == this.id && + other.name == this.name && + other.colorValue == this.colorValue && + other.description == this.description && + other.archivedAt == this.archivedAt && + other.createdAt == this.createdAt); +} + +class ProjectsCompanion extends UpdateCompanion { + final Value id; + final Value name; + final Value colorValue; + final Value description; + final Value archivedAt; + final Value createdAt; + const ProjectsCompanion({ + this.id = const Value.absent(), + this.name = const Value.absent(), + this.colorValue = const Value.absent(), + this.description = const Value.absent(), + this.archivedAt = const Value.absent(), + this.createdAt = const Value.absent(), + }); + ProjectsCompanion.insert({ + this.id = const Value.absent(), + required String name, + required int colorValue, + this.description = const Value.absent(), + this.archivedAt = const Value.absent(), + this.createdAt = const Value.absent(), + }) : name = Value(name), + colorValue = Value(colorValue); + static Insertable custom({ + Expression? id, + Expression? name, + Expression? colorValue, + Expression? description, + Expression? archivedAt, + Expression? createdAt, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (name != null) 'name': name, + if (colorValue != null) 'color_value': colorValue, + if (description != null) 'description': description, + if (archivedAt != null) 'archived_at': archivedAt, + if (createdAt != null) 'created_at': createdAt, + }); + } + + ProjectsCompanion copyWith({ + Value? id, + Value? name, + Value? colorValue, + Value? description, + Value? archivedAt, + Value? createdAt, + }) { + return ProjectsCompanion( + id: id ?? this.id, + name: name ?? this.name, + colorValue: colorValue ?? this.colorValue, + description: description ?? this.description, + archivedAt: archivedAt ?? this.archivedAt, + createdAt: createdAt ?? this.createdAt, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (colorValue.present) { + map['color_value'] = Variable(colorValue.value); + } + if (description.present) { + map['description'] = Variable(description.value); + } + if (archivedAt.present) { + map['archived_at'] = Variable(archivedAt.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ProjectsCompanion(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('colorValue: $colorValue, ') + ..write('description: $description, ') + ..write('archivedAt: $archivedAt, ') + ..write('createdAt: $createdAt') + ..write(')')) + .toString(); + } +} + +class $TimeEntriesTable extends TimeEntries + with TableInfo<$TimeEntriesTable, TimeEntry> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $TimeEntriesTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), + ); + static const VerificationMeta _projectIdMeta = const VerificationMeta( + 'projectId', + ); + @override + late final GeneratedColumn projectId = GeneratedColumn( + 'project_id', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES projects (id) ON DELETE RESTRICT', + ), + ); + static const VerificationMeta _startTimeMeta = const VerificationMeta( + 'startTime', + ); + @override + late final GeneratedColumn startTime = GeneratedColumn( + 'start_time', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + static const VerificationMeta _endTimeMeta = const VerificationMeta( + 'endTime', + ); + @override + late final GeneratedColumn endTime = GeneratedColumn( + 'end_time', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _durationSecondsMeta = const VerificationMeta( + 'durationSeconds', + ); + @override + late final GeneratedColumn durationSeconds = GeneratedColumn( + 'duration_seconds', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _noteMeta = const VerificationMeta('note'); + @override + late final GeneratedColumn note = GeneratedColumn( + 'note', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _createdAtMeta = const VerificationMeta( + 'createdAt', + ); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: currentDateAndTime, + ); + @override + List get $columns => [ + id, + projectId, + startTime, + endTime, + durationSeconds, + note, + createdAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'time_entries'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('project_id')) { + context.handle( + _projectIdMeta, + projectId.isAcceptableOrUnknown(data['project_id']!, _projectIdMeta), + ); + } else if (isInserting) { + context.missing(_projectIdMeta); + } + if (data.containsKey('start_time')) { + context.handle( + _startTimeMeta, + startTime.isAcceptableOrUnknown(data['start_time']!, _startTimeMeta), + ); + } else if (isInserting) { + context.missing(_startTimeMeta); + } + if (data.containsKey('end_time')) { + context.handle( + _endTimeMeta, + endTime.isAcceptableOrUnknown(data['end_time']!, _endTimeMeta), + ); + } + if (data.containsKey('duration_seconds')) { + context.handle( + _durationSecondsMeta, + durationSeconds.isAcceptableOrUnknown( + data['duration_seconds']!, + _durationSecondsMeta, + ), + ); + } + if (data.containsKey('note')) { + context.handle( + _noteMeta, + note.isAcceptableOrUnknown(data['note']!, _noteMeta), + ); + } + if (data.containsKey('created_at')) { + context.handle( + _createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), + ); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + TimeEntry map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return TimeEntry( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + projectId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}project_id'], + )!, + startTime: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}start_time'], + )!, + endTime: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}end_time'], + ), + durationSeconds: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}duration_seconds'], + ), + note: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}note'], + ), + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + ); + } + + @override + $TimeEntriesTable createAlias(String alias) { + return $TimeEntriesTable(attachedDatabase, alias); + } +} + +class TimeEntry extends DataClass implements Insertable { + final int id; + final int projectId; + final DateTime startTime; + final DateTime? endTime; + final int? durationSeconds; + final String? note; + final DateTime createdAt; + const TimeEntry({ + required this.id, + required this.projectId, + required this.startTime, + this.endTime, + this.durationSeconds, + this.note, + required this.createdAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['project_id'] = Variable(projectId); + map['start_time'] = Variable(startTime); + if (!nullToAbsent || endTime != null) { + map['end_time'] = Variable(endTime); + } + if (!nullToAbsent || durationSeconds != null) { + map['duration_seconds'] = Variable(durationSeconds); + } + if (!nullToAbsent || note != null) { + map['note'] = Variable(note); + } + map['created_at'] = Variable(createdAt); + return map; + } + + TimeEntriesCompanion toCompanion(bool nullToAbsent) { + return TimeEntriesCompanion( + id: Value(id), + projectId: Value(projectId), + startTime: Value(startTime), + endTime: endTime == null && nullToAbsent + ? const Value.absent() + : Value(endTime), + durationSeconds: durationSeconds == null && nullToAbsent + ? const Value.absent() + : Value(durationSeconds), + note: note == null && nullToAbsent ? const Value.absent() : Value(note), + createdAt: Value(createdAt), + ); + } + + factory TimeEntry.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return TimeEntry( + id: serializer.fromJson(json['id']), + projectId: serializer.fromJson(json['projectId']), + startTime: serializer.fromJson(json['startTime']), + endTime: serializer.fromJson(json['endTime']), + durationSeconds: serializer.fromJson(json['durationSeconds']), + note: serializer.fromJson(json['note']), + createdAt: serializer.fromJson(json['createdAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'projectId': serializer.toJson(projectId), + 'startTime': serializer.toJson(startTime), + 'endTime': serializer.toJson(endTime), + 'durationSeconds': serializer.toJson(durationSeconds), + 'note': serializer.toJson(note), + 'createdAt': serializer.toJson(createdAt), + }; + } + + TimeEntry copyWith({ + int? id, + int? projectId, + DateTime? startTime, + Value endTime = const Value.absent(), + Value durationSeconds = const Value.absent(), + Value note = const Value.absent(), + DateTime? createdAt, + }) => TimeEntry( + id: id ?? this.id, + projectId: projectId ?? this.projectId, + startTime: startTime ?? this.startTime, + endTime: endTime.present ? endTime.value : this.endTime, + durationSeconds: durationSeconds.present + ? durationSeconds.value + : this.durationSeconds, + note: note.present ? note.value : this.note, + createdAt: createdAt ?? this.createdAt, + ); + TimeEntry copyWithCompanion(TimeEntriesCompanion data) { + return TimeEntry( + id: data.id.present ? data.id.value : this.id, + projectId: data.projectId.present ? data.projectId.value : this.projectId, + startTime: data.startTime.present ? data.startTime.value : this.startTime, + endTime: data.endTime.present ? data.endTime.value : this.endTime, + durationSeconds: data.durationSeconds.present + ? data.durationSeconds.value + : this.durationSeconds, + note: data.note.present ? data.note.value : this.note, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + ); + } + + @override + String toString() { + return (StringBuffer('TimeEntry(') + ..write('id: $id, ') + ..write('projectId: $projectId, ') + ..write('startTime: $startTime, ') + ..write('endTime: $endTime, ') + ..write('durationSeconds: $durationSeconds, ') + ..write('note: $note, ') + ..write('createdAt: $createdAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + projectId, + startTime, + endTime, + durationSeconds, + note, + createdAt, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is TimeEntry && + other.id == this.id && + other.projectId == this.projectId && + other.startTime == this.startTime && + other.endTime == this.endTime && + other.durationSeconds == this.durationSeconds && + other.note == this.note && + other.createdAt == this.createdAt); +} + +class TimeEntriesCompanion extends UpdateCompanion { + final Value id; + final Value projectId; + final Value startTime; + final Value endTime; + final Value durationSeconds; + final Value note; + final Value createdAt; + const TimeEntriesCompanion({ + this.id = const Value.absent(), + this.projectId = const Value.absent(), + this.startTime = const Value.absent(), + this.endTime = const Value.absent(), + this.durationSeconds = const Value.absent(), + this.note = const Value.absent(), + this.createdAt = const Value.absent(), + }); + TimeEntriesCompanion.insert({ + this.id = const Value.absent(), + required int projectId, + required DateTime startTime, + this.endTime = const Value.absent(), + this.durationSeconds = const Value.absent(), + this.note = const Value.absent(), + this.createdAt = const Value.absent(), + }) : projectId = Value(projectId), + startTime = Value(startTime); + static Insertable custom({ + Expression? id, + Expression? projectId, + Expression? startTime, + Expression? endTime, + Expression? durationSeconds, + Expression? note, + Expression? createdAt, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (projectId != null) 'project_id': projectId, + if (startTime != null) 'start_time': startTime, + if (endTime != null) 'end_time': endTime, + if (durationSeconds != null) 'duration_seconds': durationSeconds, + if (note != null) 'note': note, + if (createdAt != null) 'created_at': createdAt, + }); + } + + TimeEntriesCompanion copyWith({ + Value? id, + Value? projectId, + Value? startTime, + Value? endTime, + Value? durationSeconds, + Value? note, + Value? createdAt, + }) { + return TimeEntriesCompanion( + id: id ?? this.id, + projectId: projectId ?? this.projectId, + startTime: startTime ?? this.startTime, + endTime: endTime ?? this.endTime, + durationSeconds: durationSeconds ?? this.durationSeconds, + note: note ?? this.note, + createdAt: createdAt ?? this.createdAt, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (projectId.present) { + map['project_id'] = Variable(projectId.value); + } + if (startTime.present) { + map['start_time'] = Variable(startTime.value); + } + if (endTime.present) { + map['end_time'] = Variable(endTime.value); + } + if (durationSeconds.present) { + map['duration_seconds'] = Variable(durationSeconds.value); + } + if (note.present) { + map['note'] = Variable(note.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('TimeEntriesCompanion(') + ..write('id: $id, ') + ..write('projectId: $projectId, ') + ..write('startTime: $startTime, ') + ..write('endTime: $endTime, ') + ..write('durationSeconds: $durationSeconds, ') + ..write('note: $note, ') + ..write('createdAt: $createdAt') + ..write(')')) + .toString(); + } +} + +class $TagsTable extends Tags with TableInfo<$TagsTable, Tag> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $TagsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), + ); + static const VerificationMeta _nameMeta = const VerificationMeta('name'); + @override + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + additionalChecks: GeneratedColumn.checkTextLength( + minTextLength: 1, + maxTextLength: 50, + ), + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways('UNIQUE'), + ); + @override + List get $columns => [id, name]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'tags'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('name')) { + context.handle( + _nameMeta, + name.isAcceptableOrUnknown(data['name']!, _nameMeta), + ); + } else if (isInserting) { + context.missing(_nameMeta); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + Tag map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return Tag( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + ); + } + + @override + $TagsTable createAlias(String alias) { + return $TagsTable(attachedDatabase, alias); + } +} + +class Tag extends DataClass implements Insertable { + final int id; + final String name; + const Tag({required this.id, required this.name}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['name'] = Variable(name); + return map; + } + + TagsCompanion toCompanion(bool nullToAbsent) { + return TagsCompanion(id: Value(id), name: Value(name)); + } + + factory Tag.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return Tag( + id: serializer.fromJson(json['id']), + name: serializer.fromJson(json['name']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'name': serializer.toJson(name), + }; + } + + Tag copyWith({int? id, String? name}) => + Tag(id: id ?? this.id, name: name ?? this.name); + Tag copyWithCompanion(TagsCompanion data) { + return Tag( + id: data.id.present ? data.id.value : this.id, + name: data.name.present ? data.name.value : this.name, + ); + } + + @override + String toString() { + return (StringBuffer('Tag(') + ..write('id: $id, ') + ..write('name: $name') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, name); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Tag && other.id == this.id && other.name == this.name); +} + +class TagsCompanion extends UpdateCompanion { + final Value id; + final Value name; + const TagsCompanion({ + this.id = const Value.absent(), + this.name = const Value.absent(), + }); + TagsCompanion.insert({this.id = const Value.absent(), required String name}) + : name = Value(name); + static Insertable custom({ + Expression? id, + Expression? name, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (name != null) 'name': name, + }); + } + + TagsCompanion copyWith({Value? id, Value? name}) { + return TagsCompanion(id: id ?? this.id, name: name ?? this.name); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('TagsCompanion(') + ..write('id: $id, ') + ..write('name: $name') + ..write(')')) + .toString(); + } +} + +class $TimeEntryTagsTable extends TimeEntryTags + with TableInfo<$TimeEntryTagsTable, TimeEntryTag> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $TimeEntryTagsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _timeEntryIdMeta = const VerificationMeta( + 'timeEntryId', + ); + @override + late final GeneratedColumn timeEntryId = GeneratedColumn( + 'time_entry_id', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES time_entries (id) ON DELETE CASCADE', + ), + ); + static const VerificationMeta _tagIdMeta = const VerificationMeta('tagId'); + @override + late final GeneratedColumn tagId = GeneratedColumn( + 'tag_id', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES tags (id) ON DELETE CASCADE', + ), + ); + @override + List get $columns => [timeEntryId, tagId]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'time_entry_tags'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('time_entry_id')) { + context.handle( + _timeEntryIdMeta, + timeEntryId.isAcceptableOrUnknown( + data['time_entry_id']!, + _timeEntryIdMeta, + ), + ); + } else if (isInserting) { + context.missing(_timeEntryIdMeta); + } + if (data.containsKey('tag_id')) { + context.handle( + _tagIdMeta, + tagId.isAcceptableOrUnknown(data['tag_id']!, _tagIdMeta), + ); + } else if (isInserting) { + context.missing(_tagIdMeta); + } + return context; + } + + @override + Set get $primaryKey => {timeEntryId, tagId}; + @override + TimeEntryTag map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return TimeEntryTag( + timeEntryId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}time_entry_id'], + )!, + tagId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}tag_id'], + )!, + ); + } + + @override + $TimeEntryTagsTable createAlias(String alias) { + return $TimeEntryTagsTable(attachedDatabase, alias); + } +} + +class TimeEntryTag extends DataClass implements Insertable { + final int timeEntryId; + final int tagId; + const TimeEntryTag({required this.timeEntryId, required this.tagId}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['time_entry_id'] = Variable(timeEntryId); + map['tag_id'] = Variable(tagId); + return map; + } + + TimeEntryTagsCompanion toCompanion(bool nullToAbsent) { + return TimeEntryTagsCompanion( + timeEntryId: Value(timeEntryId), + tagId: Value(tagId), + ); + } + + factory TimeEntryTag.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return TimeEntryTag( + timeEntryId: serializer.fromJson(json['timeEntryId']), + tagId: serializer.fromJson(json['tagId']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'timeEntryId': serializer.toJson(timeEntryId), + 'tagId': serializer.toJson(tagId), + }; + } + + TimeEntryTag copyWith({int? timeEntryId, int? tagId}) => TimeEntryTag( + timeEntryId: timeEntryId ?? this.timeEntryId, + tagId: tagId ?? this.tagId, + ); + TimeEntryTag copyWithCompanion(TimeEntryTagsCompanion data) { + return TimeEntryTag( + timeEntryId: data.timeEntryId.present + ? data.timeEntryId.value + : this.timeEntryId, + tagId: data.tagId.present ? data.tagId.value : this.tagId, + ); + } + + @override + String toString() { + return (StringBuffer('TimeEntryTag(') + ..write('timeEntryId: $timeEntryId, ') + ..write('tagId: $tagId') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(timeEntryId, tagId); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is TimeEntryTag && + other.timeEntryId == this.timeEntryId && + other.tagId == this.tagId); +} + +class TimeEntryTagsCompanion extends UpdateCompanion { + final Value timeEntryId; + final Value tagId; + final Value rowid; + const TimeEntryTagsCompanion({ + this.timeEntryId = const Value.absent(), + this.tagId = const Value.absent(), + this.rowid = const Value.absent(), + }); + TimeEntryTagsCompanion.insert({ + required int timeEntryId, + required int tagId, + this.rowid = const Value.absent(), + }) : timeEntryId = Value(timeEntryId), + tagId = Value(tagId); + static Insertable custom({ + Expression? timeEntryId, + Expression? tagId, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (timeEntryId != null) 'time_entry_id': timeEntryId, + if (tagId != null) 'tag_id': tagId, + if (rowid != null) 'rowid': rowid, + }); + } + + TimeEntryTagsCompanion copyWith({ + Value? timeEntryId, + Value? tagId, + Value? rowid, + }) { + return TimeEntryTagsCompanion( + timeEntryId: timeEntryId ?? this.timeEntryId, + tagId: tagId ?? this.tagId, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (timeEntryId.present) { + map['time_entry_id'] = Variable(timeEntryId.value); + } + if (tagId.present) { + map['tag_id'] = Variable(tagId.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('TimeEntryTagsCompanion(') + ..write('timeEntryId: $timeEntryId, ') + ..write('tagId: $tagId, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +abstract class _$AppDatabase extends GeneratedDatabase { + _$AppDatabase(QueryExecutor e) : super(e); + $AppDatabaseManager get managers => $AppDatabaseManager(this); + late final $ProjectsTable projects = $ProjectsTable(this); + late final $TimeEntriesTable timeEntries = $TimeEntriesTable(this); + late final $TagsTable tags = $TagsTable(this); + late final $TimeEntryTagsTable timeEntryTags = $TimeEntryTagsTable(this); + late final ProjectsDao projectsDao = ProjectsDao(this as AppDatabase); + late final TimeEntriesDao timeEntriesDao = TimeEntriesDao( + this as AppDatabase, + ); + late final TagsDao tagsDao = TagsDao(this as AppDatabase); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + projects, + timeEntries, + tags, + timeEntryTags, + ]; + @override + StreamQueryUpdateRules get streamUpdateRules => const StreamQueryUpdateRules([ + WritePropagation( + on: TableUpdateQuery.onTableName( + 'time_entries', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('time_entry_tags', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'tags', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('time_entry_tags', kind: UpdateKind.delete)], + ), + ]); +} + +typedef $$ProjectsTableCreateCompanionBuilder = + ProjectsCompanion Function({ + Value id, + required String name, + required int colorValue, + Value description, + Value archivedAt, + Value createdAt, + }); +typedef $$ProjectsTableUpdateCompanionBuilder = + ProjectsCompanion Function({ + Value id, + Value name, + Value colorValue, + Value description, + Value archivedAt, + Value createdAt, + }); + +final class $$ProjectsTableReferences + extends BaseReferences<_$AppDatabase, $ProjectsTable, Project> { + $$ProjectsTableReferences(super.$_db, super.$_table, super.$_typedResult); + + static MultiTypedResultKey<$TimeEntriesTable, List> + _timeEntriesRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( + db.timeEntries, + aliasName: $_aliasNameGenerator(db.projects.id, db.timeEntries.projectId), + ); + + $$TimeEntriesTableProcessedTableManager get timeEntriesRefs { + final manager = $$TimeEntriesTableTableManager( + $_db, + $_db.timeEntries, + ).filter((f) => f.projectId.id.sqlEquals($_itemColumn('id')!)); + + final cache = $_typedResult.readTableOrNull(_timeEntriesRefsTable($_db)); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } +} + +class $$ProjectsTableFilterComposer + extends Composer<_$AppDatabase, $ProjectsTable> { + $$ProjectsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get name => $composableBuilder( + column: $table.name, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get colorValue => $composableBuilder( + column: $table.colorValue, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get description => $composableBuilder( + column: $table.description, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get archivedAt => $composableBuilder( + column: $table.archivedAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnFilters(column), + ); + + Expression timeEntriesRefs( + Expression Function($$TimeEntriesTableFilterComposer f) f, + ) { + final $$TimeEntriesTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.timeEntries, + getReferencedColumn: (t) => t.projectId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$TimeEntriesTableFilterComposer( + $db: $db, + $table: $db.timeEntries, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } +} + +class $$ProjectsTableOrderingComposer + extends Composer<_$AppDatabase, $ProjectsTable> { + $$ProjectsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get name => $composableBuilder( + column: $table.name, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get colorValue => $composableBuilder( + column: $table.colorValue, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get description => $composableBuilder( + column: $table.description, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get archivedAt => $composableBuilder( + column: $table.archivedAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$ProjectsTableAnnotationComposer + extends Composer<_$AppDatabase, $ProjectsTable> { + $$ProjectsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get name => + $composableBuilder(column: $table.name, builder: (column) => column); + + GeneratedColumn get colorValue => $composableBuilder( + column: $table.colorValue, + builder: (column) => column, + ); + + GeneratedColumn get description => $composableBuilder( + column: $table.description, + builder: (column) => column, + ); + + GeneratedColumn get archivedAt => $composableBuilder( + column: $table.archivedAt, + builder: (column) => column, + ); + + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); + + Expression timeEntriesRefs( + Expression Function($$TimeEntriesTableAnnotationComposer a) f, + ) { + final $$TimeEntriesTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.timeEntries, + getReferencedColumn: (t) => t.projectId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$TimeEntriesTableAnnotationComposer( + $db: $db, + $table: $db.timeEntries, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } +} + +class $$ProjectsTableTableManager + extends + RootTableManager< + _$AppDatabase, + $ProjectsTable, + Project, + $$ProjectsTableFilterComposer, + $$ProjectsTableOrderingComposer, + $$ProjectsTableAnnotationComposer, + $$ProjectsTableCreateCompanionBuilder, + $$ProjectsTableUpdateCompanionBuilder, + (Project, $$ProjectsTableReferences), + Project, + PrefetchHooks Function({bool timeEntriesRefs}) + > { + $$ProjectsTableTableManager(_$AppDatabase db, $ProjectsTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$ProjectsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$ProjectsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$ProjectsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value name = const Value.absent(), + Value colorValue = const Value.absent(), + Value description = const Value.absent(), + Value archivedAt = const Value.absent(), + Value createdAt = const Value.absent(), + }) => ProjectsCompanion( + id: id, + name: name, + colorValue: colorValue, + description: description, + archivedAt: archivedAt, + createdAt: createdAt, + ), + createCompanionCallback: + ({ + Value id = const Value.absent(), + required String name, + required int colorValue, + Value description = const Value.absent(), + Value archivedAt = const Value.absent(), + Value createdAt = const Value.absent(), + }) => ProjectsCompanion.insert( + id: id, + name: name, + colorValue: colorValue, + description: description, + archivedAt: archivedAt, + createdAt: createdAt, + ), + withReferenceMapper: (p0) => p0 + .map( + (e) => ( + e.readTable(table), + $$ProjectsTableReferences(db, table, e), + ), + ) + .toList(), + prefetchHooksCallback: ({timeEntriesRefs = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [if (timeEntriesRefs) db.timeEntries], + addJoins: null, + getPrefetchedDataCallback: (items) async { + return [ + if (timeEntriesRefs) + await $_getPrefetchedData< + Project, + $ProjectsTable, + TimeEntry + >( + currentTable: table, + referencedTable: $$ProjectsTableReferences + ._timeEntriesRefsTable(db), + managerFromTypedResult: (p0) => $$ProjectsTableReferences( + db, + table, + p0, + ).timeEntriesRefs, + referencedItemsForCurrentItem: (item, referencedItems) => + referencedItems.where((e) => e.projectId == item.id), + typedResults: items, + ), + ]; + }, + ); + }, + ), + ); +} + +typedef $$ProjectsTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $ProjectsTable, + Project, + $$ProjectsTableFilterComposer, + $$ProjectsTableOrderingComposer, + $$ProjectsTableAnnotationComposer, + $$ProjectsTableCreateCompanionBuilder, + $$ProjectsTableUpdateCompanionBuilder, + (Project, $$ProjectsTableReferences), + Project, + PrefetchHooks Function({bool timeEntriesRefs}) + >; +typedef $$TimeEntriesTableCreateCompanionBuilder = + TimeEntriesCompanion Function({ + Value id, + required int projectId, + required DateTime startTime, + Value endTime, + Value durationSeconds, + Value note, + Value createdAt, + }); +typedef $$TimeEntriesTableUpdateCompanionBuilder = + TimeEntriesCompanion Function({ + Value id, + Value projectId, + Value startTime, + Value endTime, + Value durationSeconds, + Value note, + Value createdAt, + }); + +final class $$TimeEntriesTableReferences + extends BaseReferences<_$AppDatabase, $TimeEntriesTable, TimeEntry> { + $$TimeEntriesTableReferences(super.$_db, super.$_table, super.$_typedResult); + + static $ProjectsTable _projectIdTable(_$AppDatabase db) => + db.projects.createAlias( + $_aliasNameGenerator(db.timeEntries.projectId, db.projects.id), + ); + + $$ProjectsTableProcessedTableManager get projectId { + final $_column = $_itemColumn('project_id')!; + + final manager = $$ProjectsTableTableManager( + $_db, + $_db.projects, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_projectIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } + + static MultiTypedResultKey<$TimeEntryTagsTable, List> + _timeEntryTagsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( + db.timeEntryTags, + aliasName: $_aliasNameGenerator( + db.timeEntries.id, + db.timeEntryTags.timeEntryId, + ), + ); + + $$TimeEntryTagsTableProcessedTableManager get timeEntryTagsRefs { + final manager = $$TimeEntryTagsTableTableManager( + $_db, + $_db.timeEntryTags, + ).filter((f) => f.timeEntryId.id.sqlEquals($_itemColumn('id')!)); + + final cache = $_typedResult.readTableOrNull(_timeEntryTagsRefsTable($_db)); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } +} + +class $$TimeEntriesTableFilterComposer + extends Composer<_$AppDatabase, $TimeEntriesTable> { + $$TimeEntriesTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get startTime => $composableBuilder( + column: $table.startTime, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get endTime => $composableBuilder( + column: $table.endTime, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get durationSeconds => $composableBuilder( + column: $table.durationSeconds, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get note => $composableBuilder( + column: $table.note, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnFilters(column), + ); + + $$ProjectsTableFilterComposer get projectId { + final $$ProjectsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.projectId, + referencedTable: $db.projects, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$ProjectsTableFilterComposer( + $db: $db, + $table: $db.projects, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + + Expression timeEntryTagsRefs( + Expression Function($$TimeEntryTagsTableFilterComposer f) f, + ) { + final $$TimeEntryTagsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.timeEntryTags, + getReferencedColumn: (t) => t.timeEntryId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$TimeEntryTagsTableFilterComposer( + $db: $db, + $table: $db.timeEntryTags, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } +} + +class $$TimeEntriesTableOrderingComposer + extends Composer<_$AppDatabase, $TimeEntriesTable> { + $$TimeEntriesTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get startTime => $composableBuilder( + column: $table.startTime, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get endTime => $composableBuilder( + column: $table.endTime, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get durationSeconds => $composableBuilder( + column: $table.durationSeconds, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get note => $composableBuilder( + column: $table.note, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnOrderings(column), + ); + + $$ProjectsTableOrderingComposer get projectId { + final $$ProjectsTableOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.projectId, + referencedTable: $db.projects, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$ProjectsTableOrderingComposer( + $db: $db, + $table: $db.projects, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$TimeEntriesTableAnnotationComposer + extends Composer<_$AppDatabase, $TimeEntriesTable> { + $$TimeEntriesTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get startTime => + $composableBuilder(column: $table.startTime, builder: (column) => column); + + GeneratedColumn get endTime => + $composableBuilder(column: $table.endTime, builder: (column) => column); + + GeneratedColumn get durationSeconds => $composableBuilder( + column: $table.durationSeconds, + builder: (column) => column, + ); + + GeneratedColumn get note => + $composableBuilder(column: $table.note, builder: (column) => column); + + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); + + $$ProjectsTableAnnotationComposer get projectId { + final $$ProjectsTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.projectId, + referencedTable: $db.projects, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$ProjectsTableAnnotationComposer( + $db: $db, + $table: $db.projects, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + + Expression timeEntryTagsRefs( + Expression Function($$TimeEntryTagsTableAnnotationComposer a) f, + ) { + final $$TimeEntryTagsTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.timeEntryTags, + getReferencedColumn: (t) => t.timeEntryId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$TimeEntryTagsTableAnnotationComposer( + $db: $db, + $table: $db.timeEntryTags, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } +} + +class $$TimeEntriesTableTableManager + extends + RootTableManager< + _$AppDatabase, + $TimeEntriesTable, + TimeEntry, + $$TimeEntriesTableFilterComposer, + $$TimeEntriesTableOrderingComposer, + $$TimeEntriesTableAnnotationComposer, + $$TimeEntriesTableCreateCompanionBuilder, + $$TimeEntriesTableUpdateCompanionBuilder, + (TimeEntry, $$TimeEntriesTableReferences), + TimeEntry, + PrefetchHooks Function({bool projectId, bool timeEntryTagsRefs}) + > { + $$TimeEntriesTableTableManager(_$AppDatabase db, $TimeEntriesTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$TimeEntriesTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$TimeEntriesTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$TimeEntriesTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value projectId = const Value.absent(), + Value startTime = const Value.absent(), + Value endTime = const Value.absent(), + Value durationSeconds = const Value.absent(), + Value note = const Value.absent(), + Value createdAt = const Value.absent(), + }) => TimeEntriesCompanion( + id: id, + projectId: projectId, + startTime: startTime, + endTime: endTime, + durationSeconds: durationSeconds, + note: note, + createdAt: createdAt, + ), + createCompanionCallback: + ({ + Value id = const Value.absent(), + required int projectId, + required DateTime startTime, + Value endTime = const Value.absent(), + Value durationSeconds = const Value.absent(), + Value note = const Value.absent(), + Value createdAt = const Value.absent(), + }) => TimeEntriesCompanion.insert( + id: id, + projectId: projectId, + startTime: startTime, + endTime: endTime, + durationSeconds: durationSeconds, + note: note, + createdAt: createdAt, + ), + withReferenceMapper: (p0) => p0 + .map( + (e) => ( + e.readTable(table), + $$TimeEntriesTableReferences(db, table, e), + ), + ) + .toList(), + prefetchHooksCallback: + ({projectId = false, timeEntryTagsRefs = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [ + if (timeEntryTagsRefs) db.timeEntryTags, + ], + addJoins: + < + T extends TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic + > + >(state) { + if (projectId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.projectId, + referencedTable: + $$TimeEntriesTableReferences + ._projectIdTable(db), + referencedColumn: + $$TimeEntriesTableReferences + ._projectIdTable(db) + .id, + ) + as T; + } + + return state; + }, + getPrefetchedDataCallback: (items) async { + return [ + if (timeEntryTagsRefs) + await $_getPrefetchedData< + TimeEntry, + $TimeEntriesTable, + TimeEntryTag + >( + currentTable: table, + referencedTable: $$TimeEntriesTableReferences + ._timeEntryTagsRefsTable(db), + managerFromTypedResult: (p0) => + $$TimeEntriesTableReferences( + db, + table, + p0, + ).timeEntryTagsRefs, + referencedItemsForCurrentItem: + (item, referencedItems) => referencedItems.where( + (e) => e.timeEntryId == item.id, + ), + typedResults: items, + ), + ]; + }, + ); + }, + ), + ); +} + +typedef $$TimeEntriesTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $TimeEntriesTable, + TimeEntry, + $$TimeEntriesTableFilterComposer, + $$TimeEntriesTableOrderingComposer, + $$TimeEntriesTableAnnotationComposer, + $$TimeEntriesTableCreateCompanionBuilder, + $$TimeEntriesTableUpdateCompanionBuilder, + (TimeEntry, $$TimeEntriesTableReferences), + TimeEntry, + PrefetchHooks Function({bool projectId, bool timeEntryTagsRefs}) + >; +typedef $$TagsTableCreateCompanionBuilder = + TagsCompanion Function({Value id, required String name}); +typedef $$TagsTableUpdateCompanionBuilder = + TagsCompanion Function({Value id, Value name}); + +final class $$TagsTableReferences + extends BaseReferences<_$AppDatabase, $TagsTable, Tag> { + $$TagsTableReferences(super.$_db, super.$_table, super.$_typedResult); + + static MultiTypedResultKey<$TimeEntryTagsTable, List> + _timeEntryTagsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( + db.timeEntryTags, + aliasName: $_aliasNameGenerator(db.tags.id, db.timeEntryTags.tagId), + ); + + $$TimeEntryTagsTableProcessedTableManager get timeEntryTagsRefs { + final manager = $$TimeEntryTagsTableTableManager( + $_db, + $_db.timeEntryTags, + ).filter((f) => f.tagId.id.sqlEquals($_itemColumn('id')!)); + + final cache = $_typedResult.readTableOrNull(_timeEntryTagsRefsTable($_db)); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } +} + +class $$TagsTableFilterComposer extends Composer<_$AppDatabase, $TagsTable> { + $$TagsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get name => $composableBuilder( + column: $table.name, + builder: (column) => ColumnFilters(column), + ); + + Expression timeEntryTagsRefs( + Expression Function($$TimeEntryTagsTableFilterComposer f) f, + ) { + final $$TimeEntryTagsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.timeEntryTags, + getReferencedColumn: (t) => t.tagId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$TimeEntryTagsTableFilterComposer( + $db: $db, + $table: $db.timeEntryTags, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } +} + +class $$TagsTableOrderingComposer extends Composer<_$AppDatabase, $TagsTable> { + $$TagsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get name => $composableBuilder( + column: $table.name, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$TagsTableAnnotationComposer + extends Composer<_$AppDatabase, $TagsTable> { + $$TagsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get name => + $composableBuilder(column: $table.name, builder: (column) => column); + + Expression timeEntryTagsRefs( + Expression Function($$TimeEntryTagsTableAnnotationComposer a) f, + ) { + final $$TimeEntryTagsTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.timeEntryTags, + getReferencedColumn: (t) => t.tagId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$TimeEntryTagsTableAnnotationComposer( + $db: $db, + $table: $db.timeEntryTags, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } +} + +class $$TagsTableTableManager + extends + RootTableManager< + _$AppDatabase, + $TagsTable, + Tag, + $$TagsTableFilterComposer, + $$TagsTableOrderingComposer, + $$TagsTableAnnotationComposer, + $$TagsTableCreateCompanionBuilder, + $$TagsTableUpdateCompanionBuilder, + (Tag, $$TagsTableReferences), + Tag, + PrefetchHooks Function({bool timeEntryTagsRefs}) + > { + $$TagsTableTableManager(_$AppDatabase db, $TagsTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$TagsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$TagsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$TagsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value name = const Value.absent(), + }) => TagsCompanion(id: id, name: name), + createCompanionCallback: + ({Value id = const Value.absent(), required String name}) => + TagsCompanion.insert(id: id, name: name), + withReferenceMapper: (p0) => p0 + .map( + (e) => + (e.readTable(table), $$TagsTableReferences(db, table, e)), + ) + .toList(), + prefetchHooksCallback: ({timeEntryTagsRefs = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [ + if (timeEntryTagsRefs) db.timeEntryTags, + ], + addJoins: null, + getPrefetchedDataCallback: (items) async { + return [ + if (timeEntryTagsRefs) + await $_getPrefetchedData( + currentTable: table, + referencedTable: $$TagsTableReferences + ._timeEntryTagsRefsTable(db), + managerFromTypedResult: (p0) => $$TagsTableReferences( + db, + table, + p0, + ).timeEntryTagsRefs, + referencedItemsForCurrentItem: (item, referencedItems) => + referencedItems.where((e) => e.tagId == item.id), + typedResults: items, + ), + ]; + }, + ); + }, + ), + ); +} + +typedef $$TagsTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $TagsTable, + Tag, + $$TagsTableFilterComposer, + $$TagsTableOrderingComposer, + $$TagsTableAnnotationComposer, + $$TagsTableCreateCompanionBuilder, + $$TagsTableUpdateCompanionBuilder, + (Tag, $$TagsTableReferences), + Tag, + PrefetchHooks Function({bool timeEntryTagsRefs}) + >; +typedef $$TimeEntryTagsTableCreateCompanionBuilder = + TimeEntryTagsCompanion Function({ + required int timeEntryId, + required int tagId, + Value rowid, + }); +typedef $$TimeEntryTagsTableUpdateCompanionBuilder = + TimeEntryTagsCompanion Function({ + Value timeEntryId, + Value tagId, + Value rowid, + }); + +final class $$TimeEntryTagsTableReferences + extends BaseReferences<_$AppDatabase, $TimeEntryTagsTable, TimeEntryTag> { + $$TimeEntryTagsTableReferences( + super.$_db, + super.$_table, + super.$_typedResult, + ); + + static $TimeEntriesTable _timeEntryIdTable(_$AppDatabase db) => + db.timeEntries.createAlias( + $_aliasNameGenerator(db.timeEntryTags.timeEntryId, db.timeEntries.id), + ); + + $$TimeEntriesTableProcessedTableManager get timeEntryId { + final $_column = $_itemColumn('time_entry_id')!; + + final manager = $$TimeEntriesTableTableManager( + $_db, + $_db.timeEntries, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_timeEntryIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } + + static $TagsTable _tagIdTable(_$AppDatabase db) => db.tags.createAlias( + $_aliasNameGenerator(db.timeEntryTags.tagId, db.tags.id), + ); + + $$TagsTableProcessedTableManager get tagId { + final $_column = $_itemColumn('tag_id')!; + + final manager = $$TagsTableTableManager( + $_db, + $_db.tags, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_tagIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } +} + +class $$TimeEntryTagsTableFilterComposer + extends Composer<_$AppDatabase, $TimeEntryTagsTable> { + $$TimeEntryTagsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + $$TimeEntriesTableFilterComposer get timeEntryId { + final $$TimeEntriesTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.timeEntryId, + referencedTable: $db.timeEntries, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$TimeEntriesTableFilterComposer( + $db: $db, + $table: $db.timeEntries, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + + $$TagsTableFilterComposer get tagId { + final $$TagsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.tagId, + referencedTable: $db.tags, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$TagsTableFilterComposer( + $db: $db, + $table: $db.tags, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$TimeEntryTagsTableOrderingComposer + extends Composer<_$AppDatabase, $TimeEntryTagsTable> { + $$TimeEntryTagsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + $$TimeEntriesTableOrderingComposer get timeEntryId { + final $$TimeEntriesTableOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.timeEntryId, + referencedTable: $db.timeEntries, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$TimeEntriesTableOrderingComposer( + $db: $db, + $table: $db.timeEntries, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + + $$TagsTableOrderingComposer get tagId { + final $$TagsTableOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.tagId, + referencedTable: $db.tags, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$TagsTableOrderingComposer( + $db: $db, + $table: $db.tags, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$TimeEntryTagsTableAnnotationComposer + extends Composer<_$AppDatabase, $TimeEntryTagsTable> { + $$TimeEntryTagsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + $$TimeEntriesTableAnnotationComposer get timeEntryId { + final $$TimeEntriesTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.timeEntryId, + referencedTable: $db.timeEntries, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$TimeEntriesTableAnnotationComposer( + $db: $db, + $table: $db.timeEntries, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + + $$TagsTableAnnotationComposer get tagId { + final $$TagsTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.tagId, + referencedTable: $db.tags, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$TagsTableAnnotationComposer( + $db: $db, + $table: $db.tags, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$TimeEntryTagsTableTableManager + extends + RootTableManager< + _$AppDatabase, + $TimeEntryTagsTable, + TimeEntryTag, + $$TimeEntryTagsTableFilterComposer, + $$TimeEntryTagsTableOrderingComposer, + $$TimeEntryTagsTableAnnotationComposer, + $$TimeEntryTagsTableCreateCompanionBuilder, + $$TimeEntryTagsTableUpdateCompanionBuilder, + (TimeEntryTag, $$TimeEntryTagsTableReferences), + TimeEntryTag, + PrefetchHooks Function({bool timeEntryId, bool tagId}) + > { + $$TimeEntryTagsTableTableManager(_$AppDatabase db, $TimeEntryTagsTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$TimeEntryTagsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$TimeEntryTagsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$TimeEntryTagsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value timeEntryId = const Value.absent(), + Value tagId = const Value.absent(), + Value rowid = const Value.absent(), + }) => TimeEntryTagsCompanion( + timeEntryId: timeEntryId, + tagId: tagId, + rowid: rowid, + ), + createCompanionCallback: + ({ + required int timeEntryId, + required int tagId, + Value rowid = const Value.absent(), + }) => TimeEntryTagsCompanion.insert( + timeEntryId: timeEntryId, + tagId: tagId, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map( + (e) => ( + e.readTable(table), + $$TimeEntryTagsTableReferences(db, table, e), + ), + ) + .toList(), + prefetchHooksCallback: ({timeEntryId = false, tagId = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [], + addJoins: + < + T extends TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic + > + >(state) { + if (timeEntryId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.timeEntryId, + referencedTable: $$TimeEntryTagsTableReferences + ._timeEntryIdTable(db), + referencedColumn: $$TimeEntryTagsTableReferences + ._timeEntryIdTable(db) + .id, + ) + as T; + } + if (tagId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.tagId, + referencedTable: $$TimeEntryTagsTableReferences + ._tagIdTable(db), + referencedColumn: $$TimeEntryTagsTableReferences + ._tagIdTable(db) + .id, + ) + as T; + } + + return state; + }, + getPrefetchedDataCallback: (items) async { + return []; + }, + ); + }, + ), + ); +} + +typedef $$TimeEntryTagsTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $TimeEntryTagsTable, + TimeEntryTag, + $$TimeEntryTagsTableFilterComposer, + $$TimeEntryTagsTableOrderingComposer, + $$TimeEntryTagsTableAnnotationComposer, + $$TimeEntryTagsTableCreateCompanionBuilder, + $$TimeEntryTagsTableUpdateCompanionBuilder, + (TimeEntryTag, $$TimeEntryTagsTableReferences), + TimeEntryTag, + PrefetchHooks Function({bool timeEntryId, bool tagId}) + >; + +class $AppDatabaseManager { + final _$AppDatabase _db; + $AppDatabaseManager(this._db); + $$ProjectsTableTableManager get projects => + $$ProjectsTableTableManager(_db, _db.projects); + $$TimeEntriesTableTableManager get timeEntries => + $$TimeEntriesTableTableManager(_db, _db.timeEntries); + $$TagsTableTableManager get tags => $$TagsTableTableManager(_db, _db.tags); + $$TimeEntryTagsTableTableManager get timeEntryTags => + $$TimeEntryTagsTableTableManager(_db, _db.timeEntryTags); +} + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$appDatabaseHash() => r'96b544ff7ce456f0fc1edbdafdf332306a9affed'; + +/// See also [appDatabase]. +@ProviderFor(appDatabase) +final appDatabaseProvider = Provider.internal( + appDatabase, + name: r'appDatabaseProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$appDatabaseHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef AppDatabaseRef = ProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/core/database/connection/connection_native.dart b/lib/core/database/connection/connection_native.dart new file mode 100644 index 0000000..6780b0a --- /dev/null +++ b/lib/core/database/connection/connection_native.dart @@ -0,0 +1,14 @@ +import 'dart:io'; + +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +QueryExecutor openDatabaseConnection() { + return LazyDatabase(() async { + final dir = await getApplicationDocumentsDirectory(); + final file = File(p.join(dir.path, 'timetrack.db')); + return NativeDatabase.createInBackground(file); + }); +} diff --git a/lib/core/database/connection/connection_stub.dart b/lib/core/database/connection/connection_stub.dart new file mode 100644 index 0000000..f8a9a34 --- /dev/null +++ b/lib/core/database/connection/connection_stub.dart @@ -0,0 +1,6 @@ +import 'package:drift/drift.dart'; + +/// Stub — replaced by conditional imports in app_database.dart +QueryExecutor openDatabaseConnection() { + throw UnsupportedError('Not implemented — use platform-specific implementation'); +} diff --git a/lib/core/database/connection/connection_web.dart b/lib/core/database/connection/connection_web.dart new file mode 100644 index 0000000..6157e4e --- /dev/null +++ b/lib/core/database/connection/connection_web.dart @@ -0,0 +1,13 @@ +import 'package:drift/drift.dart'; +import 'package:drift/wasm.dart'; + +QueryExecutor openDatabaseConnection() { + return DatabaseConnection.delayed(Future(() async { + final result = await WasmDatabase.open( + databaseName: 'timetrack', + sqlite3Uri: Uri.parse('sqlite3.wasm'), + driftWorkerUri: Uri.parse('drift_worker.dart.js'), + ); + return result.resolvedExecutor; + })); +} diff --git a/lib/core/database/daos/projects_dao.dart b/lib/core/database/daos/projects_dao.dart new file mode 100644 index 0000000..520ba87 --- /dev/null +++ b/lib/core/database/daos/projects_dao.dart @@ -0,0 +1,38 @@ +import 'package:drift/drift.dart'; + +import 'package:timetrack/core/database/app_database.dart'; +import 'package:timetrack/core/database/tables/projects_table.dart'; + +part 'projects_dao.g.dart'; + +@DriftAccessor(tables: [Projects]) +class ProjectsDao extends DatabaseAccessor + with _$ProjectsDaoMixin { + ProjectsDao(super.db); + + Stream> watchAll() => select(projects).watch(); + + Stream> watchActive() => (select(projects) + ..where((t) => t.archivedAt.isNull())) + .watch(); + + Future getById(int id) => + (select(projects)..where((t) => t.id.equals(id))).getSingleOrNull(); + + Future insertProject(ProjectsCompanion companion) => + into(projects).insert(companion); + + Future updateProject(ProjectsCompanion companion) => + update(projects).replace(companion); + + Future deleteProject(int id) => + (delete(projects)..where((t) => t.id.equals(id))).go(); + + Future archiveProject(int id) => (update(projects) + ..where((t) => t.id.equals(id))) + .write(ProjectsCompanion(archivedAt: Value(DateTime.now()))); + + Future unarchiveProject(int id) => (update(projects) + ..where((t) => t.id.equals(id))) + .write(const ProjectsCompanion(archivedAt: Value(null))); +} diff --git a/lib/core/database/daos/projects_dao.g.dart b/lib/core/database/daos/projects_dao.g.dart new file mode 100644 index 0000000..bb2b23d --- /dev/null +++ b/lib/core/database/daos/projects_dao.g.dart @@ -0,0 +1,8 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'projects_dao.dart'; + +// ignore_for_file: type=lint +mixin _$ProjectsDaoMixin on DatabaseAccessor { + $ProjectsTable get projects => attachedDatabase.projects; +} diff --git a/lib/core/database/daos/tags_dao.dart b/lib/core/database/daos/tags_dao.dart new file mode 100644 index 0000000..860faf1 --- /dev/null +++ b/lib/core/database/daos/tags_dao.dart @@ -0,0 +1,40 @@ +import 'package:drift/drift.dart'; + +import 'package:timetrack/core/database/app_database.dart'; +import 'package:timetrack/core/database/tables/tags_table.dart'; +import 'package:timetrack/core/database/tables/time_entry_tags_table.dart'; + +part 'tags_dao.g.dart'; + +@DriftAccessor(tables: [Tags, TimeEntryTags]) +class TagsDao extends DatabaseAccessor with _$TagsDaoMixin { + TagsDao(super.db); + + Stream> watchAll() => select(tags).watch(); + + Future getByName(String name) => + (select(tags)..where((t) => t.name.equals(name))).getSingleOrNull(); + + Future findOrCreate(String name) async { + final existing = await getByName(name); + if (existing != null) return existing; + final id = await into(tags).insert(TagsCompanion(name: Value(name))); + return Tag(id: id, name: name); + } + + Future setTagsForEntry(int entryId, List tagNames) async { + await (delete(timeEntryTags) + ..where((t) => t.timeEntryId.equals(entryId))) + .go(); + + for (final name in tagNames) { + final tag = await findOrCreate(name); + await into(timeEntryTags).insertOnConflictUpdate( + TimeEntryTagsCompanion( + timeEntryId: Value(entryId), + tagId: Value(tag.id), + ), + ); + } + } +} diff --git a/lib/core/database/daos/tags_dao.g.dart b/lib/core/database/daos/tags_dao.g.dart new file mode 100644 index 0000000..c56cd37 --- /dev/null +++ b/lib/core/database/daos/tags_dao.g.dart @@ -0,0 +1,11 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'tags_dao.dart'; + +// ignore_for_file: type=lint +mixin _$TagsDaoMixin on DatabaseAccessor { + $TagsTable get tags => attachedDatabase.tags; + $ProjectsTable get projects => attachedDatabase.projects; + $TimeEntriesTable get timeEntries => attachedDatabase.timeEntries; + $TimeEntryTagsTable get timeEntryTags => attachedDatabase.timeEntryTags; +} diff --git a/lib/core/database/daos/time_entries_dao.dart b/lib/core/database/daos/time_entries_dao.dart new file mode 100644 index 0000000..f895260 --- /dev/null +++ b/lib/core/database/daos/time_entries_dao.dart @@ -0,0 +1,58 @@ +import 'package:drift/drift.dart'; + +import 'package:timetrack/core/database/app_database.dart'; +import 'package:timetrack/core/database/tables/time_entries_table.dart'; +import 'package:timetrack/core/database/tables/tags_table.dart'; +import 'package:timetrack/core/database/tables/time_entry_tags_table.dart'; + +part 'time_entries_dao.g.dart'; + +@DriftAccessor(tables: [TimeEntries, Tags, TimeEntryTags]) +class TimeEntriesDao extends DatabaseAccessor + with _$TimeEntriesDaoMixin { + TimeEntriesDao(super.db); + + Stream> watchAll() => + (select(timeEntries)..orderBy([(t) => OrderingTerm.desc(t.startTime)])) + .watch(); + + Stream> watchByProject(int projectId) => + (select(timeEntries) + ..where((t) => t.projectId.equals(projectId)) + ..orderBy([(t) => OrderingTerm.desc(t.startTime)])) + .watch(); + + Stream> watchByDateRange(DateTime from, DateTime to) => + (select(timeEntries) + ..where( + (t) => + t.startTime.isBiggerOrEqualValue(from) & + t.startTime.isSmallerThanValue(to), + ) + ..orderBy([(t) => OrderingTerm.desc(t.startTime)])) + .watch(); + + Future getActiveEntry() => + (select(timeEntries)..where((t) => t.endTime.isNull())) + .getSingleOrNull(); + + Future insertEntry(TimeEntriesCompanion companion) => + into(timeEntries).insert(companion); + + Future updateEntry(TimeEntriesCompanion companion) => + update(timeEntries).replace(companion); + + Future deleteEntry(int id) => + (delete(timeEntries)..where((t) => t.id.equals(id))).go(); + + Future> getTagsForEntry(int entryId) { + final query = select(tags).join([ + innerJoin( + timeEntryTags, + timeEntryTags.tagId.equalsExp(tags.id), + ), + ]) + ..where(timeEntryTags.timeEntryId.equals(entryId)); + return query.map((row) => row.readTable(tags)).get(); + } +} diff --git a/lib/core/database/daos/time_entries_dao.g.dart b/lib/core/database/daos/time_entries_dao.g.dart new file mode 100644 index 0000000..7108935 --- /dev/null +++ b/lib/core/database/daos/time_entries_dao.g.dart @@ -0,0 +1,11 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'time_entries_dao.dart'; + +// ignore_for_file: type=lint +mixin _$TimeEntriesDaoMixin on DatabaseAccessor { + $ProjectsTable get projects => attachedDatabase.projects; + $TimeEntriesTable get timeEntries => attachedDatabase.timeEntries; + $TagsTable get tags => attachedDatabase.tags; + $TimeEntryTagsTable get timeEntryTags => attachedDatabase.timeEntryTags; +} diff --git a/lib/core/database/tables/projects_table.dart b/lib/core/database/tables/projects_table.dart new file mode 100644 index 0000000..1499d19 --- /dev/null +++ b/lib/core/database/tables/projects_table.dart @@ -0,0 +1,10 @@ +import 'package:drift/drift.dart'; + +class Projects extends Table { + IntColumn get id => integer().autoIncrement()(); + TextColumn get name => text().withLength(min: 1, max: 100).unique()(); + IntColumn get colorValue => integer()(); + TextColumn get description => text().nullable()(); + DateTimeColumn get archivedAt => dateTime().nullable()(); + DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); +} diff --git a/lib/core/database/tables/tags_table.dart b/lib/core/database/tables/tags_table.dart new file mode 100644 index 0000000..9dac8ce --- /dev/null +++ b/lib/core/database/tables/tags_table.dart @@ -0,0 +1,6 @@ +import 'package:drift/drift.dart'; + +class Tags extends Table { + IntColumn get id => integer().autoIncrement()(); + TextColumn get name => text().withLength(min: 1, max: 50).unique()(); +} diff --git a/lib/core/database/tables/time_entries_table.dart b/lib/core/database/tables/time_entries_table.dart new file mode 100644 index 0000000..5e3c80f --- /dev/null +++ b/lib/core/database/tables/time_entries_table.dart @@ -0,0 +1,14 @@ +import 'package:drift/drift.dart'; + +import 'package:timetrack/core/database/tables/projects_table.dart'; + +class TimeEntries extends Table { + IntColumn get id => integer().autoIncrement()(); + IntColumn get projectId => + integer().references(Projects, #id, onDelete: KeyAction.restrict)(); + DateTimeColumn get startTime => dateTime()(); + DateTimeColumn get endTime => dateTime().nullable()(); + IntColumn get durationSeconds => integer().nullable()(); + TextColumn get note => text().nullable()(); + DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); +} diff --git a/lib/core/database/tables/time_entry_tags_table.dart b/lib/core/database/tables/time_entry_tags_table.dart new file mode 100644 index 0000000..5c0ad95 --- /dev/null +++ b/lib/core/database/tables/time_entry_tags_table.dart @@ -0,0 +1,14 @@ +import 'package:drift/drift.dart'; + +import 'package:timetrack/core/database/tables/time_entries_table.dart'; +import 'package:timetrack/core/database/tables/tags_table.dart'; + +class TimeEntryTags extends Table { + IntColumn get timeEntryId => + integer().references(TimeEntries, #id, onDelete: KeyAction.cascade)(); + IntColumn get tagId => + integer().references(Tags, #id, onDelete: KeyAction.cascade)(); + + @override + Set get primaryKey => {timeEntryId, tagId}; +} diff --git a/lib/core/l10n/app_de.arb b/lib/core/l10n/app_de.arb new file mode 100644 index 0000000..2003fa6 --- /dev/null +++ b/lib/core/l10n/app_de.arb @@ -0,0 +1,104 @@ +{ + "@@locale": "de", + "navTimer": "Timer", + "navEntries": "Einträge", + "navProjects": "Projekte", + "navReports": "Berichte", + "navSettings": "Einstellungen", + "timerStart": "Starten", + "timerStop": "Stoppen", + "timerDiscard": "Verwerfen", + "timerDiscardConfirm": "Eintrag verwerfen?", + "timerDiscardBody": "Der aktuelle Zeiteintrag wird gelöscht.", + "timerSelectProject": "Projekt wählen", + "timerAddNote": "Notiz hinzufügen…", + "timerTodayTotal": "Heute: {duration}", + "@timerTodayTotal": { + "placeholders": { + "duration": { "type": "String" } + } + }, + "timerRecentToday": "Heute zuletzt", + "entriesTitle": "Einträge", + "entriesEmpty": "Noch keine Einträge.", + "entriesAdd": "Eintrag hinzufügen", + "entriesDeleted": "Eintrag gelöscht", + "entriesUndo": "Rückgängig", + "projectsTitle": "Projekte", + "projectsEmpty": "Noch keine Projekte.", + "projectsArchivedEmpty": "Keine archivierten Projekte.", + "projectsAdd": "Neues Projekt", + "projectsActive": "Aktiv", + "projectsArchived": "Archiviert", + "projectsName": "Name", + "projectsDescription": "Beschreibung (optional)", + "projectsArchive": "Archivieren", + "projectsUnarchive": "Wiederherstellen", + "projectsDeleteTitle": "Projekt löschen?", + "projectsDeleteBlocked": "Löschen nicht möglich — Projekt hat Zeiteinträge.", + "projectsColorLabel": "Farbe", + "reportsTitle": "Berichte", + "reportsDay": "Tag", + "reportsWeek": "Woche", + "reportsMonth": "Monat", + "reportsTotal": "Gesamt", + "reportsNoData": "Keine Daten für diesen Zeitraum", + "reportsByProject": "Nach Projekt", + "timerQuickAccess": "Schnellzugriff", + "timerSwitched": "{stopped} gestoppt, {started} gestartet", + "@timerSwitched": { + "placeholders": { + "stopped": { "type": "String" }, + "started": { "type": "String" } + } + }, + "timerStopped": "{name} gestoppt", + "@timerStopped": { + "placeholders": { + "name": { "type": "String" } + } + }, + "settingsTitle": "Einstellungen", + "settingsAppearance": "Darstellung", + "settingsTheme": "Design", + "settingsThemeSystem": "System", + "settingsThemeLight": "Hell", + "settingsThemeDark": "Dunkel", + "settingsLanguage": "Sprache", + "settingsLanguageSystem": "System", + "settingsTimer": "Timer", + "settingsQuickAccessCount": "Schnellzugriff-Projekte", + "settingsQuickAccessCountHint": "Anzahl der häufig genutzten Projekte im Schnellzugriff ({min}–{max})", + "@settingsQuickAccessCountHint": { + "placeholders": { + "min": { "type": "int" }, + "max": { "type": "int" } + } + }, + "settingsData": "Daten", + "settingsExport": "Daten exportieren", + "settingsAbout": "Über", + "settingsVersion": "Version", + "settingsLicences": "Lizenzen", + "exportTitle": "Daten exportieren", + "exportFormat": "Format", + "exportRange": "Zeitraum", + "exportProject": "Projekt (optional)", + "exportAllProjects": "Alle Projekte", + "exportShare": "Teilen", + "exportFailed": "Export fehlgeschlagen: {error}", + "@exportFailed": { + "placeholders": { + "error": { "type": "String" } + } + }, + "cancel": "Abbrechen", + "save": "Speichern", + "delete": "Löschen", + "edit": "Bearbeiten", + "confirm": "Bestätigen", + "today": "Heute", + "yesterday": "Gestern", + "thisWeek": "Diese Woche", + "thisMonth": "Diesen Monat" +} diff --git a/lib/core/l10n/app_en.arb b/lib/core/l10n/app_en.arb new file mode 100644 index 0000000..e3b5767 --- /dev/null +++ b/lib/core/l10n/app_en.arb @@ -0,0 +1,104 @@ +{ + "@@locale": "en", + "navTimer": "Timer", + "navEntries": "Entries", + "navProjects": "Projects", + "navReports": "Reports", + "navSettings": "Settings", + "timerStart": "Start", + "timerStop": "Stop", + "timerDiscard": "Discard", + "timerDiscardConfirm": "Discard this entry?", + "timerDiscardBody": "The current time entry will be deleted.", + "timerSelectProject": "Select project", + "timerAddNote": "Add a note…", + "timerTodayTotal": "Today: {duration}", + "@timerTodayTotal": { + "placeholders": { + "duration": { "type": "String" } + } + }, + "timerRecentToday": "Recent today", + "entriesTitle": "Entries", + "entriesEmpty": "No entries yet.", + "entriesAdd": "Add entry", + "entriesDeleted": "Entry deleted", + "entriesUndo": "Undo", + "projectsTitle": "Projects", + "projectsEmpty": "No projects yet.", + "projectsArchivedEmpty": "No archived projects.", + "projectsAdd": "New project", + "projectsActive": "Active", + "projectsArchived": "Archived", + "projectsName": "Name", + "projectsDescription": "Description (optional)", + "projectsArchive": "Archive", + "projectsUnarchive": "Unarchive", + "projectsDeleteTitle": "Delete project?", + "projectsDeleteBlocked": "Cannot delete — project has time entries.", + "projectsColorLabel": "Color", + "reportsTitle": "Reports", + "reportsDay": "Day", + "reportsWeek": "Week", + "reportsMonth": "Month", + "reportsTotal": "Total", + "reportsNoData": "No data for this period", + "reportsByProject": "By project", + "timerQuickAccess": "Quick Access", + "timerSwitched": "{stopped} stopped, {started} started", + "@timerSwitched": { + "placeholders": { + "stopped": { "type": "String" }, + "started": { "type": "String" } + } + }, + "timerStopped": "{name} stopped", + "@timerStopped": { + "placeholders": { + "name": { "type": "String" } + } + }, + "settingsTitle": "Settings", + "settingsAppearance": "Appearance", + "settingsTheme": "Theme", + "settingsThemeSystem": "System", + "settingsThemeLight": "Light", + "settingsThemeDark": "Dark", + "settingsLanguage": "Language", + "settingsLanguageSystem": "System", + "settingsTimer": "Timer", + "settingsQuickAccessCount": "Quick access projects", + "settingsQuickAccessCountHint": "Number of frequently used projects shown in quick access ({min}–{max})", + "@settingsQuickAccessCountHint": { + "placeholders": { + "min": { "type": "int" }, + "max": { "type": "int" } + } + }, + "settingsData": "Data", + "settingsExport": "Export data", + "settingsAbout": "About", + "settingsVersion": "Version", + "settingsLicences": "Licences", + "exportTitle": "Export data", + "exportFormat": "Format", + "exportRange": "Date range", + "exportProject": "Project (optional)", + "exportAllProjects": "All projects", + "exportShare": "Share", + "exportFailed": "Export failed: {error}", + "@exportFailed": { + "placeholders": { + "error": { "type": "String" } + } + }, + "cancel": "Cancel", + "save": "Save", + "delete": "Delete", + "edit": "Edit", + "confirm": "Confirm", + "today": "Today", + "yesterday": "Yesterday", + "thisWeek": "This week", + "thisMonth": "This month" +} diff --git a/lib/core/l10n/app_localizations.dart b/lib/core/l10n/app_localizations.dart new file mode 100644 index 0000000..96bef1b --- /dev/null +++ b/lib/core/l10n/app_localizations.dart @@ -0,0 +1,578 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:intl/intl.dart' as intl; + +import 'app_localizations_de.dart'; +import 'app_localizations_en.dart'; + +// ignore_for_file: type=lint + +/// Callers can lookup localized strings with an instance of AppLocalizations +/// returned by `AppLocalizations.of(context)`. +/// +/// Applications need to include `AppLocalizations.delegate()` in their app's +/// `localizationDelegates` list, and the locales they support in the app's +/// `supportedLocales` list. For example: +/// +/// ```dart +/// import 'l10n/app_localizations.dart'; +/// +/// return MaterialApp( +/// localizationsDelegates: AppLocalizations.localizationsDelegates, +/// supportedLocales: AppLocalizations.supportedLocales, +/// home: MyApplicationHome(), +/// ); +/// ``` +/// +/// ## Update pubspec.yaml +/// +/// Please make sure to update your pubspec.yaml to include the following +/// packages: +/// +/// ```yaml +/// dependencies: +/// # Internationalization support. +/// flutter_localizations: +/// sdk: flutter +/// intl: any # Use the pinned version from flutter_localizations +/// +/// # Rest of dependencies +/// ``` +/// +/// ## iOS Applications +/// +/// iOS applications define key application metadata, including supported +/// locales, in an Info.plist file that is built into the application bundle. +/// To configure the locales supported by your app, you’ll need to edit this +/// file. +/// +/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file. +/// Then, in the Project Navigator, open the Info.plist file under the Runner +/// project’s Runner folder. +/// +/// Next, select the Information Property List item, select Add Item from the +/// Editor menu, then select Localizations from the pop-up menu. +/// +/// Select and expand the newly-created Localizations item then, for each +/// locale your application supports, add a new item and select the locale +/// you wish to add from the pop-up menu in the Value field. This list should +/// be consistent with the languages listed in the AppLocalizations.supportedLocales +/// property. +abstract class AppLocalizations { + AppLocalizations(String locale) + : localeName = intl.Intl.canonicalizedLocale(locale.toString()); + + final String localeName; + + static AppLocalizations? of(BuildContext context) { + return Localizations.of(context, AppLocalizations); + } + + static const LocalizationsDelegate delegate = + _AppLocalizationsDelegate(); + + /// A list of this localizations delegate along with the default localizations + /// delegates. + /// + /// Returns a list of localizations delegates containing this delegate along with + /// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate, + /// and GlobalWidgetsLocalizations.delegate. + /// + /// Additional delegates can be added by appending to this list in + /// MaterialApp. This list does not have to be used at all if a custom list + /// of delegates is preferred or required. + static const List> localizationsDelegates = + >[ + delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ]; + + /// A list of this localizations delegate's supported locales. + static const List supportedLocales = [ + Locale('de'), + Locale('en'), + ]; + + /// No description provided for @navTimer. + /// + /// In en, this message translates to: + /// **'Timer'** + String get navTimer; + + /// No description provided for @navEntries. + /// + /// In en, this message translates to: + /// **'Entries'** + String get navEntries; + + /// No description provided for @navProjects. + /// + /// In en, this message translates to: + /// **'Projects'** + String get navProjects; + + /// No description provided for @navReports. + /// + /// In en, this message translates to: + /// **'Reports'** + String get navReports; + + /// No description provided for @navSettings. + /// + /// In en, this message translates to: + /// **'Settings'** + String get navSettings; + + /// No description provided for @timerStart. + /// + /// In en, this message translates to: + /// **'Start'** + String get timerStart; + + /// No description provided for @timerStop. + /// + /// In en, this message translates to: + /// **'Stop'** + String get timerStop; + + /// No description provided for @timerDiscard. + /// + /// In en, this message translates to: + /// **'Discard'** + String get timerDiscard; + + /// No description provided for @timerDiscardConfirm. + /// + /// In en, this message translates to: + /// **'Discard this entry?'** + String get timerDiscardConfirm; + + /// No description provided for @timerDiscardBody. + /// + /// In en, this message translates to: + /// **'The current time entry will be deleted.'** + String get timerDiscardBody; + + /// No description provided for @timerSelectProject. + /// + /// In en, this message translates to: + /// **'Select project'** + String get timerSelectProject; + + /// No description provided for @timerAddNote. + /// + /// In en, this message translates to: + /// **'Add a note…'** + String get timerAddNote; + + /// No description provided for @timerTodayTotal. + /// + /// In en, this message translates to: + /// **'Today: {duration}'** + String timerTodayTotal(String duration); + + /// No description provided for @timerRecentToday. + /// + /// In en, this message translates to: + /// **'Recent today'** + String get timerRecentToday; + + /// No description provided for @entriesTitle. + /// + /// In en, this message translates to: + /// **'Entries'** + String get entriesTitle; + + /// No description provided for @entriesEmpty. + /// + /// In en, this message translates to: + /// **'No entries yet.'** + String get entriesEmpty; + + /// No description provided for @entriesAdd. + /// + /// In en, this message translates to: + /// **'Add entry'** + String get entriesAdd; + + /// No description provided for @entriesDeleted. + /// + /// In en, this message translates to: + /// **'Entry deleted'** + String get entriesDeleted; + + /// No description provided for @entriesUndo. + /// + /// In en, this message translates to: + /// **'Undo'** + String get entriesUndo; + + /// No description provided for @projectsTitle. + /// + /// In en, this message translates to: + /// **'Projects'** + String get projectsTitle; + + /// No description provided for @projectsEmpty. + /// + /// In en, this message translates to: + /// **'No projects yet.'** + String get projectsEmpty; + + /// No description provided for @projectsArchivedEmpty. + /// + /// In en, this message translates to: + /// **'No archived projects.'** + String get projectsArchivedEmpty; + + /// No description provided for @projectsAdd. + /// + /// In en, this message translates to: + /// **'New project'** + String get projectsAdd; + + /// No description provided for @projectsActive. + /// + /// In en, this message translates to: + /// **'Active'** + String get projectsActive; + + /// No description provided for @projectsArchived. + /// + /// In en, this message translates to: + /// **'Archived'** + String get projectsArchived; + + /// No description provided for @projectsName. + /// + /// In en, this message translates to: + /// **'Name'** + String get projectsName; + + /// No description provided for @projectsDescription. + /// + /// In en, this message translates to: + /// **'Description (optional)'** + String get projectsDescription; + + /// No description provided for @projectsArchive. + /// + /// In en, this message translates to: + /// **'Archive'** + String get projectsArchive; + + /// No description provided for @projectsUnarchive. + /// + /// In en, this message translates to: + /// **'Unarchive'** + String get projectsUnarchive; + + /// No description provided for @projectsDeleteTitle. + /// + /// In en, this message translates to: + /// **'Delete project?'** + String get projectsDeleteTitle; + + /// No description provided for @projectsDeleteBlocked. + /// + /// In en, this message translates to: + /// **'Cannot delete — project has time entries.'** + String get projectsDeleteBlocked; + + /// No description provided for @projectsColorLabel. + /// + /// In en, this message translates to: + /// **'Color'** + String get projectsColorLabel; + + /// No description provided for @reportsTitle. + /// + /// In en, this message translates to: + /// **'Reports'** + String get reportsTitle; + + /// No description provided for @reportsDay. + /// + /// In en, this message translates to: + /// **'Day'** + String get reportsDay; + + /// No description provided for @reportsWeek. + /// + /// In en, this message translates to: + /// **'Week'** + String get reportsWeek; + + /// No description provided for @reportsMonth. + /// + /// In en, this message translates to: + /// **'Month'** + String get reportsMonth; + + /// No description provided for @reportsTotal. + /// + /// In en, this message translates to: + /// **'Total'** + String get reportsTotal; + + /// No description provided for @reportsNoData. + /// + /// In en, this message translates to: + /// **'No data for this period'** + String get reportsNoData; + + /// No description provided for @reportsByProject. + /// + /// In en, this message translates to: + /// **'By project'** + String get reportsByProject; + + /// No description provided for @timerQuickAccess. + /// + /// In en, this message translates to: + /// **'Quick Access'** + String get timerQuickAccess; + + /// No description provided for @timerSwitched. + /// + /// In en, this message translates to: + /// **'{stopped} stopped, {started} started'** + String timerSwitched(String stopped, String started); + + /// No description provided for @timerStopped. + /// + /// In en, this message translates to: + /// **'{name} stopped'** + String timerStopped(String name); + + /// No description provided for @settingsTitle. + /// + /// In en, this message translates to: + /// **'Settings'** + String get settingsTitle; + + /// No description provided for @settingsAppearance. + /// + /// In en, this message translates to: + /// **'Appearance'** + String get settingsAppearance; + + /// No description provided for @settingsTheme. + /// + /// In en, this message translates to: + /// **'Theme'** + String get settingsTheme; + + /// No description provided for @settingsThemeSystem. + /// + /// In en, this message translates to: + /// **'System'** + String get settingsThemeSystem; + + /// No description provided for @settingsThemeLight. + /// + /// In en, this message translates to: + /// **'Light'** + String get settingsThemeLight; + + /// No description provided for @settingsThemeDark. + /// + /// In en, this message translates to: + /// **'Dark'** + String get settingsThemeDark; + + /// No description provided for @settingsLanguage. + /// + /// In en, this message translates to: + /// **'Language'** + String get settingsLanguage; + + /// No description provided for @settingsLanguageSystem. + /// + /// In en, this message translates to: + /// **'System'** + String get settingsLanguageSystem; + + /// No description provided for @settingsTimer. + /// + /// In en, this message translates to: + /// **'Timer'** + String get settingsTimer; + + /// No description provided for @settingsQuickAccessCount. + /// + /// In en, this message translates to: + /// **'Quick access projects'** + String get settingsQuickAccessCount; + + /// No description provided for @settingsQuickAccessCountHint. + /// + /// In en, this message translates to: + /// **'Number of frequently used projects shown in quick access ({min}–{max})'** + String settingsQuickAccessCountHint(int min, int max); + + /// No description provided for @settingsData. + /// + /// In en, this message translates to: + /// **'Data'** + String get settingsData; + + /// No description provided for @settingsExport. + /// + /// In en, this message translates to: + /// **'Export data'** + String get settingsExport; + + /// No description provided for @settingsAbout. + /// + /// In en, this message translates to: + /// **'About'** + String get settingsAbout; + + /// No description provided for @settingsVersion. + /// + /// In en, this message translates to: + /// **'Version'** + String get settingsVersion; + + /// No description provided for @settingsLicences. + /// + /// In en, this message translates to: + /// **'Licences'** + String get settingsLicences; + + /// No description provided for @exportTitle. + /// + /// In en, this message translates to: + /// **'Export data'** + String get exportTitle; + + /// No description provided for @exportFormat. + /// + /// In en, this message translates to: + /// **'Format'** + String get exportFormat; + + /// No description provided for @exportRange. + /// + /// In en, this message translates to: + /// **'Date range'** + String get exportRange; + + /// No description provided for @exportProject. + /// + /// In en, this message translates to: + /// **'Project (optional)'** + String get exportProject; + + /// No description provided for @exportAllProjects. + /// + /// In en, this message translates to: + /// **'All projects'** + String get exportAllProjects; + + /// No description provided for @exportShare. + /// + /// In en, this message translates to: + /// **'Share'** + String get exportShare; + + /// No description provided for @exportFailed. + /// + /// In en, this message translates to: + /// **'Export failed: {error}'** + String exportFailed(String error); + + /// No description provided for @cancel. + /// + /// In en, this message translates to: + /// **'Cancel'** + String get cancel; + + /// No description provided for @save. + /// + /// In en, this message translates to: + /// **'Save'** + String get save; + + /// No description provided for @delete. + /// + /// In en, this message translates to: + /// **'Delete'** + String get delete; + + /// No description provided for @edit. + /// + /// In en, this message translates to: + /// **'Edit'** + String get edit; + + /// No description provided for @confirm. + /// + /// In en, this message translates to: + /// **'Confirm'** + String get confirm; + + /// No description provided for @today. + /// + /// In en, this message translates to: + /// **'Today'** + String get today; + + /// No description provided for @yesterday. + /// + /// In en, this message translates to: + /// **'Yesterday'** + String get yesterday; + + /// No description provided for @thisWeek. + /// + /// In en, this message translates to: + /// **'This week'** + String get thisWeek; + + /// No description provided for @thisMonth. + /// + /// In en, this message translates to: + /// **'This month'** + String get thisMonth; +} + +class _AppLocalizationsDelegate + extends LocalizationsDelegate { + const _AppLocalizationsDelegate(); + + @override + Future load(Locale locale) { + return SynchronousFuture(lookupAppLocalizations(locale)); + } + + @override + bool isSupported(Locale locale) => + ['de', 'en'].contains(locale.languageCode); + + @override + bool shouldReload(_AppLocalizationsDelegate old) => false; +} + +AppLocalizations lookupAppLocalizations(Locale locale) { + // Lookup logic when only language code is specified. + switch (locale.languageCode) { + case 'de': + return AppLocalizationsDe(); + case 'en': + return AppLocalizationsEn(); + } + + throw FlutterError( + 'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' + 'an issue with the localizations generation tool. Please file an issue ' + 'on GitHub with a reproducible sample app and the gen-l10n configuration ' + 'that was used.', + ); +} diff --git a/lib/core/l10n/app_localizations_de.dart b/lib/core/l10n/app_localizations_de.dart new file mode 100644 index 0000000..c4b69fd --- /dev/null +++ b/lib/core/l10n/app_localizations_de.dart @@ -0,0 +1,243 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for German (`de`). +class AppLocalizationsDe extends AppLocalizations { + AppLocalizationsDe([String locale = 'de']) : super(locale); + + @override + String get navTimer => 'Timer'; + + @override + String get navEntries => 'Einträge'; + + @override + String get navProjects => 'Projekte'; + + @override + String get navReports => 'Berichte'; + + @override + String get navSettings => 'Einstellungen'; + + @override + String get timerStart => 'Starten'; + + @override + String get timerStop => 'Stoppen'; + + @override + String get timerDiscard => 'Verwerfen'; + + @override + String get timerDiscardConfirm => 'Eintrag verwerfen?'; + + @override + String get timerDiscardBody => 'Der aktuelle Zeiteintrag wird gelöscht.'; + + @override + String get timerSelectProject => 'Projekt wählen'; + + @override + String get timerAddNote => 'Notiz hinzufügen…'; + + @override + String timerTodayTotal(String duration) { + return 'Heute: $duration'; + } + + @override + String get timerRecentToday => 'Heute zuletzt'; + + @override + String get entriesTitle => 'Einträge'; + + @override + String get entriesEmpty => 'Noch keine Einträge.'; + + @override + String get entriesAdd => 'Eintrag hinzufügen'; + + @override + String get entriesDeleted => 'Eintrag gelöscht'; + + @override + String get entriesUndo => 'Rückgängig'; + + @override + String get projectsTitle => 'Projekte'; + + @override + String get projectsEmpty => 'Noch keine Projekte.'; + + @override + String get projectsArchivedEmpty => 'Keine archivierten Projekte.'; + + @override + String get projectsAdd => 'Neues Projekt'; + + @override + String get projectsActive => 'Aktiv'; + + @override + String get projectsArchived => 'Archiviert'; + + @override + String get projectsName => 'Name'; + + @override + String get projectsDescription => 'Beschreibung (optional)'; + + @override + String get projectsArchive => 'Archivieren'; + + @override + String get projectsUnarchive => 'Wiederherstellen'; + + @override + String get projectsDeleteTitle => 'Projekt löschen?'; + + @override + String get projectsDeleteBlocked => + 'Löschen nicht möglich — Projekt hat Zeiteinträge.'; + + @override + String get projectsColorLabel => 'Farbe'; + + @override + String get reportsTitle => 'Berichte'; + + @override + String get reportsDay => 'Tag'; + + @override + String get reportsWeek => 'Woche'; + + @override + String get reportsMonth => 'Monat'; + + @override + String get reportsTotal => 'Gesamt'; + + @override + String get reportsNoData => 'Keine Daten für diesen Zeitraum'; + + @override + String get reportsByProject => 'Nach Projekt'; + + @override + String get timerQuickAccess => 'Schnellzugriff'; + + @override + String timerSwitched(String stopped, String started) { + return '$stopped gestoppt, $started gestartet'; + } + + @override + String timerStopped(String name) { + return '$name gestoppt'; + } + + @override + String get settingsTitle => 'Einstellungen'; + + @override + String get settingsAppearance => 'Darstellung'; + + @override + String get settingsTheme => 'Design'; + + @override + String get settingsThemeSystem => 'System'; + + @override + String get settingsThemeLight => 'Hell'; + + @override + String get settingsThemeDark => 'Dunkel'; + + @override + String get settingsLanguage => 'Sprache'; + + @override + String get settingsLanguageSystem => 'System'; + + @override + String get settingsTimer => 'Timer'; + + @override + String get settingsQuickAccessCount => 'Schnellzugriff-Projekte'; + + @override + String settingsQuickAccessCountHint(int min, int max) { + return 'Anzahl der häufig genutzten Projekte im Schnellzugriff ($min–$max)'; + } + + @override + String get settingsData => 'Daten'; + + @override + String get settingsExport => 'Daten exportieren'; + + @override + String get settingsAbout => 'Über'; + + @override + String get settingsVersion => 'Version'; + + @override + String get settingsLicences => 'Lizenzen'; + + @override + String get exportTitle => 'Daten exportieren'; + + @override + String get exportFormat => 'Format'; + + @override + String get exportRange => 'Zeitraum'; + + @override + String get exportProject => 'Projekt (optional)'; + + @override + String get exportAllProjects => 'Alle Projekte'; + + @override + String get exportShare => 'Teilen'; + + @override + String exportFailed(String error) { + return 'Export fehlgeschlagen: $error'; + } + + @override + String get cancel => 'Abbrechen'; + + @override + String get save => 'Speichern'; + + @override + String get delete => 'Löschen'; + + @override + String get edit => 'Bearbeiten'; + + @override + String get confirm => 'Bestätigen'; + + @override + String get today => 'Heute'; + + @override + String get yesterday => 'Gestern'; + + @override + String get thisWeek => 'Diese Woche'; + + @override + String get thisMonth => 'Diesen Monat'; +} diff --git a/lib/core/l10n/app_localizations_en.dart b/lib/core/l10n/app_localizations_en.dart new file mode 100644 index 0000000..d258af7 --- /dev/null +++ b/lib/core/l10n/app_localizations_en.dart @@ -0,0 +1,243 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for English (`en`). +class AppLocalizationsEn extends AppLocalizations { + AppLocalizationsEn([String locale = 'en']) : super(locale); + + @override + String get navTimer => 'Timer'; + + @override + String get navEntries => 'Entries'; + + @override + String get navProjects => 'Projects'; + + @override + String get navReports => 'Reports'; + + @override + String get navSettings => 'Settings'; + + @override + String get timerStart => 'Start'; + + @override + String get timerStop => 'Stop'; + + @override + String get timerDiscard => 'Discard'; + + @override + String get timerDiscardConfirm => 'Discard this entry?'; + + @override + String get timerDiscardBody => 'The current time entry will be deleted.'; + + @override + String get timerSelectProject => 'Select project'; + + @override + String get timerAddNote => 'Add a note…'; + + @override + String timerTodayTotal(String duration) { + return 'Today: $duration'; + } + + @override + String get timerRecentToday => 'Recent today'; + + @override + String get entriesTitle => 'Entries'; + + @override + String get entriesEmpty => 'No entries yet.'; + + @override + String get entriesAdd => 'Add entry'; + + @override + String get entriesDeleted => 'Entry deleted'; + + @override + String get entriesUndo => 'Undo'; + + @override + String get projectsTitle => 'Projects'; + + @override + String get projectsEmpty => 'No projects yet.'; + + @override + String get projectsArchivedEmpty => 'No archived projects.'; + + @override + String get projectsAdd => 'New project'; + + @override + String get projectsActive => 'Active'; + + @override + String get projectsArchived => 'Archived'; + + @override + String get projectsName => 'Name'; + + @override + String get projectsDescription => 'Description (optional)'; + + @override + String get projectsArchive => 'Archive'; + + @override + String get projectsUnarchive => 'Unarchive'; + + @override + String get projectsDeleteTitle => 'Delete project?'; + + @override + String get projectsDeleteBlocked => + 'Cannot delete — project has time entries.'; + + @override + String get projectsColorLabel => 'Color'; + + @override + String get reportsTitle => 'Reports'; + + @override + String get reportsDay => 'Day'; + + @override + String get reportsWeek => 'Week'; + + @override + String get reportsMonth => 'Month'; + + @override + String get reportsTotal => 'Total'; + + @override + String get reportsNoData => 'No data for this period'; + + @override + String get reportsByProject => 'By project'; + + @override + String get timerQuickAccess => 'Quick Access'; + + @override + String timerSwitched(String stopped, String started) { + return '$stopped stopped, $started started'; + } + + @override + String timerStopped(String name) { + return '$name stopped'; + } + + @override + String get settingsTitle => 'Settings'; + + @override + String get settingsAppearance => 'Appearance'; + + @override + String get settingsTheme => 'Theme'; + + @override + String get settingsThemeSystem => 'System'; + + @override + String get settingsThemeLight => 'Light'; + + @override + String get settingsThemeDark => 'Dark'; + + @override + String get settingsLanguage => 'Language'; + + @override + String get settingsLanguageSystem => 'System'; + + @override + String get settingsTimer => 'Timer'; + + @override + String get settingsQuickAccessCount => 'Quick access projects'; + + @override + String settingsQuickAccessCountHint(int min, int max) { + return 'Number of frequently used projects shown in quick access ($min–$max)'; + } + + @override + String get settingsData => 'Data'; + + @override + String get settingsExport => 'Export data'; + + @override + String get settingsAbout => 'About'; + + @override + String get settingsVersion => 'Version'; + + @override + String get settingsLicences => 'Licences'; + + @override + String get exportTitle => 'Export data'; + + @override + String get exportFormat => 'Format'; + + @override + String get exportRange => 'Date range'; + + @override + String get exportProject => 'Project (optional)'; + + @override + String get exportAllProjects => 'All projects'; + + @override + String get exportShare => 'Share'; + + @override + String exportFailed(String error) { + return 'Export failed: $error'; + } + + @override + String get cancel => 'Cancel'; + + @override + String get save => 'Save'; + + @override + String get delete => 'Delete'; + + @override + String get edit => 'Edit'; + + @override + String get confirm => 'Confirm'; + + @override + String get today => 'Today'; + + @override + String get yesterday => 'Yesterday'; + + @override + String get thisWeek => 'This week'; + + @override + String get thisMonth => 'This month'; +} diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart new file mode 100644 index 0000000..4c317df --- /dev/null +++ b/lib/core/router/app_router.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:timetrack/features/timer/presentation/timer_screen.dart'; +import 'package:timetrack/features/entries/presentation/entries_screen.dart'; +import 'package:timetrack/features/projects/presentation/projects_screen.dart'; +import 'package:timetrack/features/reports/presentation/reports_screen.dart'; +import 'package:timetrack/features/settings/presentation/settings_screen.dart'; + +part 'app_router.g.dart'; + +@riverpod +GoRouter appRouter(AppRouterRef ref) { + return GoRouter( + initialLocation: AppRoutes.timer, + routes: [ + StatefulShellRoute.indexedStack( + builder: (context, state, navigationShell) => + ScaffoldWithNavBar(navigationShell: navigationShell), + branches: [ + StatefulShellBranch(routes: [ + GoRoute(path: AppRoutes.timer, builder: (context, state) => const TimerScreen()), + ]), + StatefulShellBranch(routes: [ + GoRoute(path: AppRoutes.entries, builder: (context, state) => const EntriesScreen()), + ]), + StatefulShellBranch(routes: [ + GoRoute(path: AppRoutes.projects, builder: (context, state) => const ProjectsScreen()), + ]), + StatefulShellBranch(routes: [ + GoRoute(path: AppRoutes.reports, builder: (context, state) => const ReportsScreen()), + ]), + StatefulShellBranch(routes: [ + GoRoute(path: AppRoutes.settings, builder: (context, state) => const SettingsScreen()), + ]), + ], + ), + ], + ); +} + +class AppRoutes { + AppRoutes._(); + static const timer = '/timer'; + static const entries = '/entries'; + static const projects = '/projects'; + static const reports = '/reports'; + static const settings = '/settings'; +} + +class ScaffoldWithNavBar extends StatelessWidget { + const ScaffoldWithNavBar({super.key, required this.navigationShell}); + + final StatefulNavigationShell navigationShell; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: navigationShell, + bottomNavigationBar: NavigationBar( + selectedIndex: navigationShell.currentIndex, + onDestinationSelected: navigationShell.goBranch, + destinations: const [ + NavigationDestination(icon: Icon(Icons.timer_outlined), selectedIcon: Icon(Icons.timer), label: 'Timer'), + NavigationDestination(icon: Icon(Icons.list_outlined), selectedIcon: Icon(Icons.list), label: 'Entries'), + NavigationDestination(icon: Icon(Icons.folder_outlined), selectedIcon: Icon(Icons.folder), label: 'Projects'), + NavigationDestination(icon: Icon(Icons.bar_chart_outlined), selectedIcon: Icon(Icons.bar_chart), label: 'Reports'), + NavigationDestination(icon: Icon(Icons.settings_outlined), selectedIcon: Icon(Icons.settings), label: 'Settings'), + ], + ), + ); + } +} diff --git a/lib/core/router/app_router.g.dart b/lib/core/router/app_router.g.dart new file mode 100644 index 0000000..49d5919 --- /dev/null +++ b/lib/core/router/app_router.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'app_router.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$appRouterHash() => r'2bb4c3ea3b905f2ceb2d77b598671abe33b3a6c4'; + +/// See also [appRouter]. +@ProviderFor(appRouter) +final appRouterProvider = AutoDisposeProvider.internal( + appRouter, + name: r'appRouterProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$appRouterHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef AppRouterRef = AutoDisposeProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart new file mode 100644 index 0000000..5554a78 --- /dev/null +++ b/lib/core/theme/app_theme.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; + +class AppTheme { + AppTheme._(); + + static final ColorScheme _lightColorScheme = ColorScheme.fromSeed( + seedColor: const Color(0xFF2563EB), + brightness: Brightness.light, + ); + + static final ColorScheme _darkColorScheme = ColorScheme.fromSeed( + seedColor: const Color(0xFF2563EB), + brightness: Brightness.dark, + ); + + static final ThemeData light = ThemeData( + useMaterial3: true, + colorScheme: _lightColorScheme, + appBarTheme: const AppBarTheme(centerTitle: true), + ); + + static final ThemeData dark = ThemeData( + useMaterial3: true, + colorScheme: _darkColorScheme, + appBarTheme: const AppBarTheme(centerTitle: true), + ); +} diff --git a/lib/features/entries/data/drift_entries_repository.dart b/lib/features/entries/data/drift_entries_repository.dart new file mode 100644 index 0000000..155fafe --- /dev/null +++ b/lib/features/entries/data/drift_entries_repository.dart @@ -0,0 +1,70 @@ +import 'package:drift/drift.dart' show Value; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:timetrack/core/database/app_database.dart' as db; +import 'package:timetrack/features/entries/data/entries_repository.dart'; +import 'package:timetrack/features/entries/data/time_entry_mapper.dart'; +import 'package:timetrack/features/entries/domain/time_entry.dart'; + +part 'drift_entries_repository.g.dart'; + +class DriftTimeEntriesRepository implements TimeEntriesRepository { + DriftTimeEntriesRepository(this._db); + + final db.AppDatabase _db; + + @override + Stream> watchAll() => + _db.timeEntriesDao.watchAll().asyncMap(_enrichWithTags); + + @override + Stream> watchByProject(int projectId) => + _db.timeEntriesDao.watchByProject(projectId).asyncMap(_enrichWithTags); + + @override + Stream> watchByDateRange(DateTime from, DateTime to) => + _db.timeEntriesDao.watchByDateRange(from, to).asyncMap(_enrichWithTags); + + @override + Future getActiveEntry() async { + final row = await _db.timeEntriesDao.getActiveEntry(); + if (row == null) return null; + final tags = await _db.timeEntriesDao.getTagsForEntry(row.id); + return row.toDomain(tags: tags.map((t) => t.name).toList()); + } + + @override + Future create(TimeEntry entry) async { + final id = await _db.timeEntriesDao.insertEntry(entry.toInsertCompanion()); + if (entry.tags.isNotEmpty) { + await _db.tagsDao.setTagsForEntry(id, entry.tags); + } + return id; + } + + @override + Future update(TimeEntry entry) async { + await _db.timeEntriesDao.updateEntry( + entry.toInsertCompanion().copyWith(id: Value(entry.id)), + ); + await _db.tagsDao.setTagsForEntry(entry.id, entry.tags); + } + + @override + Future delete(int id) => _db.timeEntriesDao.deleteEntry(id); + + Future> _enrichWithTags(List rows) async { + final result = []; + for (final row in rows) { + final tags = await _db.timeEntriesDao.getTagsForEntry(row.id); + result.add(row.toDomain(tags: tags.map((t) => t.name).toList())); + } + return result; + } +} + +@riverpod +TimeEntriesRepository timeEntriesRepository(TimeEntriesRepositoryRef ref) { + final database = ref.watch(db.appDatabaseProvider); + return DriftTimeEntriesRepository(database); +} diff --git a/lib/features/entries/data/drift_entries_repository.g.dart b/lib/features/entries/data/drift_entries_repository.g.dart new file mode 100644 index 0000000..3f465ab --- /dev/null +++ b/lib/features/entries/data/drift_entries_repository.g.dart @@ -0,0 +1,30 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'drift_entries_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$timeEntriesRepositoryHash() => + r'056e73e2116987aab3ec53e6be1186a38042c6b6'; + +/// See also [timeEntriesRepository]. +@ProviderFor(timeEntriesRepository) +final timeEntriesRepositoryProvider = + AutoDisposeProvider.internal( + timeEntriesRepository, + name: r'timeEntriesRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$timeEntriesRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef TimeEntriesRepositoryRef = + AutoDisposeProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/entries/data/drift_tags_repository.dart b/lib/features/entries/data/drift_tags_repository.dart new file mode 100644 index 0000000..df0606e --- /dev/null +++ b/lib/features/entries/data/drift_tags_repository.dart @@ -0,0 +1,40 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:timetrack/core/database/app_database.dart' as db; +import 'package:timetrack/features/entries/data/tags_repository.dart'; +import 'package:timetrack/features/entries/domain/tag.dart'; + +part 'drift_tags_repository.g.dart'; + +class DriftTagsRepository implements TagsRepository { + DriftTagsRepository(this._db); + + final db.AppDatabase _db; + + @override + Stream> watchAll() => _db.tagsDao + .watchAll() + .map((rows) => rows.map((r) => Tag(id: r.id, name: r.name)).toList()); + + @override + Future findOrCreate(String name) async { + final row = await _db.tagsDao.findOrCreate(name); + return Tag(id: row.id, name: row.name); + } + + @override + Future setTagsForEntry(int entryId, List tagNames) => + _db.tagsDao.setTagsForEntry(entryId, tagNames); + + @override + Future> getTagsForEntry(int entryId) async { + final rows = await _db.timeEntriesDao.getTagsForEntry(entryId); + return rows.map((r) => Tag(id: r.id, name: r.name)).toList(); + } +} + +@riverpod +TagsRepository tagsRepository(TagsRepositoryRef ref) { + final database = ref.watch(db.appDatabaseProvider); + return DriftTagsRepository(database); +} diff --git a/lib/features/entries/data/drift_tags_repository.g.dart b/lib/features/entries/data/drift_tags_repository.g.dart new file mode 100644 index 0000000..1778fed --- /dev/null +++ b/lib/features/entries/data/drift_tags_repository.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'drift_tags_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$tagsRepositoryHash() => r'595b0d16943cf871edd01ada65abe92bc8df3cc0'; + +/// See also [tagsRepository]. +@ProviderFor(tagsRepository) +final tagsRepositoryProvider = AutoDisposeProvider.internal( + tagsRepository, + name: r'tagsRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$tagsRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef TagsRepositoryRef = AutoDisposeProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/entries/data/entries_repository.dart b/lib/features/entries/data/entries_repository.dart new file mode 100644 index 0000000..0a7c141 --- /dev/null +++ b/lib/features/entries/data/entries_repository.dart @@ -0,0 +1,11 @@ +import 'package:timetrack/features/entries/domain/time_entry.dart'; + +abstract class TimeEntriesRepository { + Stream> watchAll(); + Stream> watchByProject(int projectId); + Stream> watchByDateRange(DateTime from, DateTime to); + Future getActiveEntry(); + Future create(TimeEntry entry); + Future update(TimeEntry entry); + Future delete(int id); +} diff --git a/lib/features/entries/data/tags_repository.dart b/lib/features/entries/data/tags_repository.dart new file mode 100644 index 0000000..b7aa05e --- /dev/null +++ b/lib/features/entries/data/tags_repository.dart @@ -0,0 +1,8 @@ +import 'package:timetrack/features/entries/domain/tag.dart'; + +abstract class TagsRepository { + Stream> watchAll(); + Future findOrCreate(String name); + Future setTagsForEntry(int entryId, List tagNames); + Future> getTagsForEntry(int entryId); +} diff --git a/lib/features/entries/data/time_entry_mapper.dart b/lib/features/entries/data/time_entry_mapper.dart new file mode 100644 index 0000000..59c26b3 --- /dev/null +++ b/lib/features/entries/data/time_entry_mapper.dart @@ -0,0 +1,28 @@ +import 'package:drift/drift.dart' show Value; + +import 'package:timetrack/core/database/app_database.dart' as db; +import 'package:timetrack/features/entries/domain/time_entry.dart'; + +extension TimeEntryMapper on db.TimeEntry { + TimeEntry toDomain({List tags = const []}) => TimeEntry( + id: id, + projectId: projectId, + startTime: startTime, + endTime: endTime, + durationSeconds: durationSeconds, + note: note, + tags: tags, + createdAt: createdAt, + ); +} + +extension TimeEntryDomainMapper on TimeEntry { + db.TimeEntriesCompanion toInsertCompanion() => + db.TimeEntriesCompanion.insert( + projectId: projectId, + startTime: startTime, + endTime: Value(endTime), + durationSeconds: Value(durationSeconds), + note: Value(note), + ); +} diff --git a/lib/features/entries/domain/entries_provider.dart b/lib/features/entries/domain/entries_provider.dart new file mode 100644 index 0000000..c319c12 --- /dev/null +++ b/lib/features/entries/domain/entries_provider.dart @@ -0,0 +1,31 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:timetrack/features/entries/data/drift_entries_repository.dart'; +import 'package:timetrack/features/entries/data/entries_repository.dart'; +import 'package:timetrack/features/entries/domain/time_entry.dart'; + +part 'entries_provider.g.dart'; + +@riverpod +Stream> entriesByDateRange( + EntriesByDateRangeRef ref, { + required DateTime rangeFrom, + required DateTime rangeTo, +}) => + ref.watch(timeEntriesRepositoryProvider).watchByDateRange(rangeFrom, rangeTo); + +@riverpod +Stream> allEntries(AllEntriesRef ref) => + ref.watch(timeEntriesRepositoryProvider).watchAll(); + +@riverpod +class EntriesNotifier extends _$EntriesNotifier { + @override + Future build() async {} + + TimeEntriesRepository get _repo => ref.read(timeEntriesRepositoryProvider); + + Future create(TimeEntry entry) => _repo.create(entry); + Future updateEntry(TimeEntry entry) => _repo.update(entry); + Future delete(int id) => _repo.delete(id); +} diff --git a/lib/features/entries/domain/entries_provider.g.dart b/lib/features/entries/domain/entries_provider.g.dart new file mode 100644 index 0000000..56c9921 --- /dev/null +++ b/lib/features/entries/domain/entries_provider.g.dart @@ -0,0 +1,207 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'entries_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$entriesByDateRangeHash() => + r'e6a786d595652bc09b8706987c0533652a310413'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +/// See also [entriesByDateRange]. +@ProviderFor(entriesByDateRange) +const entriesByDateRangeProvider = EntriesByDateRangeFamily(); + +/// See also [entriesByDateRange]. +class EntriesByDateRangeFamily extends Family>> { + /// See also [entriesByDateRange]. + const EntriesByDateRangeFamily(); + + /// See also [entriesByDateRange]. + EntriesByDateRangeProvider call({ + required DateTime rangeFrom, + required DateTime rangeTo, + }) { + return EntriesByDateRangeProvider(rangeFrom: rangeFrom, rangeTo: rangeTo); + } + + @override + EntriesByDateRangeProvider getProviderOverride( + covariant EntriesByDateRangeProvider provider, + ) { + return call(rangeFrom: provider.rangeFrom, rangeTo: provider.rangeTo); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'entriesByDateRangeProvider'; +} + +/// See also [entriesByDateRange]. +class EntriesByDateRangeProvider + extends AutoDisposeStreamProvider> { + /// See also [entriesByDateRange]. + EntriesByDateRangeProvider({ + required DateTime rangeFrom, + required DateTime rangeTo, + }) : this._internal( + (ref) => entriesByDateRange( + ref as EntriesByDateRangeRef, + rangeFrom: rangeFrom, + rangeTo: rangeTo, + ), + from: entriesByDateRangeProvider, + name: r'entriesByDateRangeProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$entriesByDateRangeHash, + dependencies: EntriesByDateRangeFamily._dependencies, + allTransitiveDependencies: + EntriesByDateRangeFamily._allTransitiveDependencies, + rangeFrom: rangeFrom, + rangeTo: rangeTo, + ); + + EntriesByDateRangeProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.rangeFrom, + required this.rangeTo, + }) : super.internal(); + + final DateTime rangeFrom; + final DateTime rangeTo; + + @override + Override overrideWith( + Stream> Function(EntriesByDateRangeRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: EntriesByDateRangeProvider._internal( + (ref) => create(ref as EntriesByDateRangeRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + rangeFrom: rangeFrom, + rangeTo: rangeTo, + ), + ); + } + + @override + AutoDisposeStreamProviderElement> createElement() { + return _EntriesByDateRangeProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is EntriesByDateRangeProvider && + other.rangeFrom == rangeFrom && + other.rangeTo == rangeTo; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, rangeFrom.hashCode); + hash = _SystemHash.combine(hash, rangeTo.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin EntriesByDateRangeRef on AutoDisposeStreamProviderRef> { + /// The parameter `rangeFrom` of this provider. + DateTime get rangeFrom; + + /// The parameter `rangeTo` of this provider. + DateTime get rangeTo; +} + +class _EntriesByDateRangeProviderElement + extends AutoDisposeStreamProviderElement> + with EntriesByDateRangeRef { + _EntriesByDateRangeProviderElement(super.provider); + + @override + DateTime get rangeFrom => (origin as EntriesByDateRangeProvider).rangeFrom; + @override + DateTime get rangeTo => (origin as EntriesByDateRangeProvider).rangeTo; +} + +String _$allEntriesHash() => r'8824b948dda9520cafd6925c8ddb20da001627a1'; + +/// See also [allEntries]. +@ProviderFor(allEntries) +final allEntriesProvider = AutoDisposeStreamProvider>.internal( + allEntries, + name: r'allEntriesProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$allEntriesHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef AllEntriesRef = AutoDisposeStreamProviderRef>; +String _$entriesNotifierHash() => r'27b9614dcba4a5be0b562a7ba8fb619d8e0d22e1'; + +/// See also [EntriesNotifier]. +@ProviderFor(EntriesNotifier) +final entriesNotifierProvider = + AutoDisposeAsyncNotifierProvider.internal( + EntriesNotifier.new, + name: r'entriesNotifierProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$entriesNotifierHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$EntriesNotifier = AutoDisposeAsyncNotifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/entries/domain/tag.dart b/lib/features/entries/domain/tag.dart new file mode 100644 index 0000000..8560158 --- /dev/null +++ b/lib/features/entries/domain/tag.dart @@ -0,0 +1,14 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'tag.freezed.dart'; +part 'tag.g.dart'; + +@freezed +class Tag with _$Tag { + const factory Tag({ + required int id, + required String name, + }) = _Tag; + + factory Tag.fromJson(Map json) => _$TagFromJson(json); +} diff --git a/lib/features/entries/domain/tag.freezed.dart b/lib/features/entries/domain/tag.freezed.dart new file mode 100644 index 0000000..2b1d925 --- /dev/null +++ b/lib/features/entries/domain/tag.freezed.dart @@ -0,0 +1,171 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'tag.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models', +); + +Tag _$TagFromJson(Map json) { + return _Tag.fromJson(json); +} + +/// @nodoc +mixin _$Tag { + int get id => throw _privateConstructorUsedError; + String get name => throw _privateConstructorUsedError; + + /// Serializes this Tag to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of Tag + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $TagCopyWith get copyWith => throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $TagCopyWith<$Res> { + factory $TagCopyWith(Tag value, $Res Function(Tag) then) = + _$TagCopyWithImpl<$Res, Tag>; + @useResult + $Res call({int id, String name}); +} + +/// @nodoc +class _$TagCopyWithImpl<$Res, $Val extends Tag> implements $TagCopyWith<$Res> { + _$TagCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of Tag + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({Object? id = null, Object? name = null}) { + return _then( + _value.copyWith( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as int, + name: null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + ) + as $Val, + ); + } +} + +/// @nodoc +abstract class _$$TagImplCopyWith<$Res> implements $TagCopyWith<$Res> { + factory _$$TagImplCopyWith(_$TagImpl value, $Res Function(_$TagImpl) then) = + __$$TagImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({int id, String name}); +} + +/// @nodoc +class __$$TagImplCopyWithImpl<$Res> extends _$TagCopyWithImpl<$Res, _$TagImpl> + implements _$$TagImplCopyWith<$Res> { + __$$TagImplCopyWithImpl(_$TagImpl _value, $Res Function(_$TagImpl) _then) + : super(_value, _then); + + /// Create a copy of Tag + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({Object? id = null, Object? name = null}) { + return _then( + _$TagImpl( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as int, + name: null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + ), + ); + } +} + +/// @nodoc +@JsonSerializable() +class _$TagImpl implements _Tag { + const _$TagImpl({required this.id, required this.name}); + + factory _$TagImpl.fromJson(Map json) => + _$$TagImplFromJson(json); + + @override + final int id; + @override + final String name; + + @override + String toString() { + return 'Tag(id: $id, name: $name)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$TagImpl && + (identical(other.id, id) || other.id == id) && + (identical(other.name, name) || other.name == name)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, id, name); + + /// Create a copy of Tag + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$TagImplCopyWith<_$TagImpl> get copyWith => + __$$TagImplCopyWithImpl<_$TagImpl>(this, _$identity); + + @override + Map toJson() { + return _$$TagImplToJson(this); + } +} + +abstract class _Tag implements Tag { + const factory _Tag({required final int id, required final String name}) = + _$TagImpl; + + factory _Tag.fromJson(Map json) = _$TagImpl.fromJson; + + @override + int get id; + @override + String get name; + + /// Create a copy of Tag + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$TagImplCopyWith<_$TagImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/features/entries/domain/tag.g.dart b/lib/features/entries/domain/tag.g.dart new file mode 100644 index 0000000..11e3105 --- /dev/null +++ b/lib/features/entries/domain/tag.g.dart @@ -0,0 +1,15 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'tag.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$TagImpl _$$TagImplFromJson(Map json) => + _$TagImpl(id: (json['id'] as num).toInt(), name: json['name'] as String); + +Map _$$TagImplToJson(_$TagImpl instance) => { + 'id': instance.id, + 'name': instance.name, +}; diff --git a/lib/features/entries/domain/time_entry.dart b/lib/features/entries/domain/time_entry.dart new file mode 100644 index 0000000..d1e7d60 --- /dev/null +++ b/lib/features/entries/domain/time_entry.dart @@ -0,0 +1,21 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'time_entry.freezed.dart'; +part 'time_entry.g.dart'; + +@freezed +class TimeEntry with _$TimeEntry { + const factory TimeEntry({ + required int id, + required int projectId, + required DateTime startTime, + DateTime? endTime, + int? durationSeconds, + String? note, + @Default([]) List tags, + required DateTime createdAt, + }) = _TimeEntry; + + factory TimeEntry.fromJson(Map json) => + _$TimeEntryFromJson(json); +} diff --git a/lib/features/entries/domain/time_entry.freezed.dart b/lib/features/entries/domain/time_entry.freezed.dart new file mode 100644 index 0000000..85c7611 --- /dev/null +++ b/lib/features/entries/domain/time_entry.freezed.dart @@ -0,0 +1,338 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'time_entry.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models', +); + +TimeEntry _$TimeEntryFromJson(Map json) { + return _TimeEntry.fromJson(json); +} + +/// @nodoc +mixin _$TimeEntry { + int get id => throw _privateConstructorUsedError; + int get projectId => throw _privateConstructorUsedError; + DateTime get startTime => throw _privateConstructorUsedError; + DateTime? get endTime => throw _privateConstructorUsedError; + int? get durationSeconds => throw _privateConstructorUsedError; + String? get note => throw _privateConstructorUsedError; + List get tags => throw _privateConstructorUsedError; + DateTime get createdAt => throw _privateConstructorUsedError; + + /// Serializes this TimeEntry to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of TimeEntry + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $TimeEntryCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $TimeEntryCopyWith<$Res> { + factory $TimeEntryCopyWith(TimeEntry value, $Res Function(TimeEntry) then) = + _$TimeEntryCopyWithImpl<$Res, TimeEntry>; + @useResult + $Res call({ + int id, + int projectId, + DateTime startTime, + DateTime? endTime, + int? durationSeconds, + String? note, + List tags, + DateTime createdAt, + }); +} + +/// @nodoc +class _$TimeEntryCopyWithImpl<$Res, $Val extends TimeEntry> + implements $TimeEntryCopyWith<$Res> { + _$TimeEntryCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of TimeEntry + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? projectId = null, + Object? startTime = null, + Object? endTime = freezed, + Object? durationSeconds = freezed, + Object? note = freezed, + Object? tags = null, + Object? createdAt = null, + }) { + return _then( + _value.copyWith( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as int, + projectId: null == projectId + ? _value.projectId + : projectId // ignore: cast_nullable_to_non_nullable + as int, + startTime: null == startTime + ? _value.startTime + : startTime // ignore: cast_nullable_to_non_nullable + as DateTime, + endTime: freezed == endTime + ? _value.endTime + : endTime // ignore: cast_nullable_to_non_nullable + as DateTime?, + durationSeconds: freezed == durationSeconds + ? _value.durationSeconds + : durationSeconds // ignore: cast_nullable_to_non_nullable + as int?, + note: freezed == note + ? _value.note + : note // ignore: cast_nullable_to_non_nullable + as String?, + tags: null == tags + ? _value.tags + : tags // ignore: cast_nullable_to_non_nullable + as List, + createdAt: null == createdAt + ? _value.createdAt + : createdAt // ignore: cast_nullable_to_non_nullable + as DateTime, + ) + as $Val, + ); + } +} + +/// @nodoc +abstract class _$$TimeEntryImplCopyWith<$Res> + implements $TimeEntryCopyWith<$Res> { + factory _$$TimeEntryImplCopyWith( + _$TimeEntryImpl value, + $Res Function(_$TimeEntryImpl) then, + ) = __$$TimeEntryImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({ + int id, + int projectId, + DateTime startTime, + DateTime? endTime, + int? durationSeconds, + String? note, + List tags, + DateTime createdAt, + }); +} + +/// @nodoc +class __$$TimeEntryImplCopyWithImpl<$Res> + extends _$TimeEntryCopyWithImpl<$Res, _$TimeEntryImpl> + implements _$$TimeEntryImplCopyWith<$Res> { + __$$TimeEntryImplCopyWithImpl( + _$TimeEntryImpl _value, + $Res Function(_$TimeEntryImpl) _then, + ) : super(_value, _then); + + /// Create a copy of TimeEntry + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? projectId = null, + Object? startTime = null, + Object? endTime = freezed, + Object? durationSeconds = freezed, + Object? note = freezed, + Object? tags = null, + Object? createdAt = null, + }) { + return _then( + _$TimeEntryImpl( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as int, + projectId: null == projectId + ? _value.projectId + : projectId // ignore: cast_nullable_to_non_nullable + as int, + startTime: null == startTime + ? _value.startTime + : startTime // ignore: cast_nullable_to_non_nullable + as DateTime, + endTime: freezed == endTime + ? _value.endTime + : endTime // ignore: cast_nullable_to_non_nullable + as DateTime?, + durationSeconds: freezed == durationSeconds + ? _value.durationSeconds + : durationSeconds // ignore: cast_nullable_to_non_nullable + as int?, + note: freezed == note + ? _value.note + : note // ignore: cast_nullable_to_non_nullable + as String?, + tags: null == tags + ? _value._tags + : tags // ignore: cast_nullable_to_non_nullable + as List, + createdAt: null == createdAt + ? _value.createdAt + : createdAt // ignore: cast_nullable_to_non_nullable + as DateTime, + ), + ); + } +} + +/// @nodoc +@JsonSerializable() +class _$TimeEntryImpl implements _TimeEntry { + const _$TimeEntryImpl({ + required this.id, + required this.projectId, + required this.startTime, + this.endTime, + this.durationSeconds, + this.note, + final List tags = const [], + required this.createdAt, + }) : _tags = tags; + + factory _$TimeEntryImpl.fromJson(Map json) => + _$$TimeEntryImplFromJson(json); + + @override + final int id; + @override + final int projectId; + @override + final DateTime startTime; + @override + final DateTime? endTime; + @override + final int? durationSeconds; + @override + final String? note; + final List _tags; + @override + @JsonKey() + List get tags { + if (_tags is EqualUnmodifiableListView) return _tags; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_tags); + } + + @override + final DateTime createdAt; + + @override + String toString() { + return 'TimeEntry(id: $id, projectId: $projectId, startTime: $startTime, endTime: $endTime, durationSeconds: $durationSeconds, note: $note, tags: $tags, createdAt: $createdAt)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$TimeEntryImpl && + (identical(other.id, id) || other.id == id) && + (identical(other.projectId, projectId) || + other.projectId == projectId) && + (identical(other.startTime, startTime) || + other.startTime == startTime) && + (identical(other.endTime, endTime) || other.endTime == endTime) && + (identical(other.durationSeconds, durationSeconds) || + other.durationSeconds == durationSeconds) && + (identical(other.note, note) || other.note == note) && + const DeepCollectionEquality().equals(other._tags, _tags) && + (identical(other.createdAt, createdAt) || + other.createdAt == createdAt)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, + id, + projectId, + startTime, + endTime, + durationSeconds, + note, + const DeepCollectionEquality().hash(_tags), + createdAt, + ); + + /// Create a copy of TimeEntry + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$TimeEntryImplCopyWith<_$TimeEntryImpl> get copyWith => + __$$TimeEntryImplCopyWithImpl<_$TimeEntryImpl>(this, _$identity); + + @override + Map toJson() { + return _$$TimeEntryImplToJson(this); + } +} + +abstract class _TimeEntry implements TimeEntry { + const factory _TimeEntry({ + required final int id, + required final int projectId, + required final DateTime startTime, + final DateTime? endTime, + final int? durationSeconds, + final String? note, + final List tags, + required final DateTime createdAt, + }) = _$TimeEntryImpl; + + factory _TimeEntry.fromJson(Map json) = + _$TimeEntryImpl.fromJson; + + @override + int get id; + @override + int get projectId; + @override + DateTime get startTime; + @override + DateTime? get endTime; + @override + int? get durationSeconds; + @override + String? get note; + @override + List get tags; + @override + DateTime get createdAt; + + /// Create a copy of TimeEntry + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$TimeEntryImplCopyWith<_$TimeEntryImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/features/entries/domain/time_entry.g.dart b/lib/features/entries/domain/time_entry.g.dart new file mode 100644 index 0000000..9f2d7c3 --- /dev/null +++ b/lib/features/entries/domain/time_entry.g.dart @@ -0,0 +1,35 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'time_entry.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$TimeEntryImpl _$$TimeEntryImplFromJson(Map json) => + _$TimeEntryImpl( + id: (json['id'] as num).toInt(), + projectId: (json['projectId'] as num).toInt(), + startTime: DateTime.parse(json['startTime'] as String), + endTime: json['endTime'] == null + ? null + : DateTime.parse(json['endTime'] as String), + durationSeconds: (json['durationSeconds'] as num?)?.toInt(), + note: json['note'] as String?, + tags: + (json['tags'] as List?)?.map((e) => e as String).toList() ?? + const [], + createdAt: DateTime.parse(json['createdAt'] as String), + ); + +Map _$$TimeEntryImplToJson(_$TimeEntryImpl instance) => + { + 'id': instance.id, + 'projectId': instance.projectId, + 'startTime': instance.startTime.toIso8601String(), + 'endTime': instance.endTime?.toIso8601String(), + 'durationSeconds': instance.durationSeconds, + 'note': instance.note, + 'tags': instance.tags, + 'createdAt': instance.createdAt.toIso8601String(), + }; diff --git a/lib/features/entries/presentation/entries_screen.dart b/lib/features/entries/presentation/entries_screen.dart new file mode 100644 index 0000000..82bad02 --- /dev/null +++ b/lib/features/entries/presentation/entries_screen.dart @@ -0,0 +1,135 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:timetrack/features/entries/domain/entries_provider.dart'; +import 'package:timetrack/features/entries/domain/time_entry.dart'; +import 'package:timetrack/features/entries/presentation/widgets/date_range_filter.dart'; +import 'package:timetrack/features/entries/presentation/widgets/day_header.dart'; +import 'package:timetrack/features/entries/presentation/widgets/entry_form_sheet.dart'; +import 'package:timetrack/features/entries/presentation/widgets/entry_list_tile.dart'; +import 'package:timetrack/features/projects/domain/project.dart'; +import 'package:timetrack/features/projects/domain/projects_provider.dart'; + +class EntriesScreen extends ConsumerStatefulWidget { + const EntriesScreen({super.key}); + + @override + ConsumerState createState() => _EntriesScreenState(); +} + +class _EntriesScreenState extends ConsumerState { + DateRangeFilter _filter = DateRangeFilter.thisWeek; + + @override + Widget build(BuildContext context) { + final (from, to) = dateRangeBounds(_filter); + final entriesAsync = ref.watch( + entriesByDateRangeProvider(rangeFrom: from, rangeTo: to), + ); + final projectsAsync = ref.watch(allProjectsProvider); + + return Scaffold( + appBar: AppBar( + title: const Text('Entries'), + actions: [ + IconButton( + icon: const Icon(Icons.add), + tooltip: 'Add entry', + onPressed: () => EntryFormSheet.show(context), + ), + ], + ), + body: Column( + children: [ + DateRangeFilterChips( + selected: _filter, + onChanged: (f) => setState(() => _filter = f), + ), + Expanded( + child: entriesAsync.when( + data: (entries) => projectsAsync.when( + data: (projects) => _buildList(entries, projects), + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('Error: $e')), + ), + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('Error: $e')), + ), + ), + ], + ), + ); + } + + Widget _buildList(List entries, List projects) { + if (entries.isEmpty) { + return const Center( + key: Key('entries_empty'), + child: Text('No entries yet.'), + ); + } + + final projectMap = {for (final p in projects) p.id: p}; + + // Group by date + final grouped = >{}; + for (final entry in entries) { + final day = DateTime( + entry.startTime.year, + entry.startTime.month, + entry.startTime.day, + ); + grouped.putIfAbsent(day, () => []).add(entry); + } + + final sortedDays = grouped.keys.toList() + ..sort((a, b) => b.compareTo(a)); + + return ListView.builder( + itemCount: sortedDays.length, + itemBuilder: (context, index) { + final day = sortedDays[index]; + final dayEntries = grouped[day]!; + final totalSeconds = dayEntries.fold( + 0, + (sum, e) => sum + (e.durationSeconds ?? 0), + ); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DayHeader( + date: day, + totalDuration: Duration(seconds: totalSeconds), + ), + ...dayEntries.map((entry) { + final project = projectMap[entry.projectId]; + return EntryListTile( + key: Key('entry_tile_${entry.id}'), + entry: entry, + projectName: project?.name ?? '—', + projectColor: project?.colorValue ?? 0xFF888888, + onTap: () => EntryFormSheet.show(context, existing: entry), + onDelete: () => _deleteWithUndo(entry), + ); + }), + ], + ); + }, + ); + } + + void _deleteWithUndo(TimeEntry entry) { + ref.read(entriesNotifierProvider.notifier).delete(entry.id); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Entry deleted'), + action: SnackBarAction( + label: 'Undo', + onPressed: () => + ref.read(entriesNotifierProvider.notifier).create(entry), + ), + ), + ); + } +} diff --git a/lib/features/entries/presentation/widgets/date_range_filter.dart b/lib/features/entries/presentation/widgets/date_range_filter.dart new file mode 100644 index 0000000..d4fd5e2 --- /dev/null +++ b/lib/features/entries/presentation/widgets/date_range_filter.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; + +enum DateRangeFilter { today, thisWeek, thisMonth } + +class DateRangeFilterChips extends StatelessWidget { + const DateRangeFilterChips({ + super.key, + required this.selected, + required this.onChanged, + }); + + final DateRangeFilter selected; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Row( + children: [ + _chip(context, DateRangeFilter.today, 'Today'), + const SizedBox(width: 8), + _chip(context, DateRangeFilter.thisWeek, 'This week'), + const SizedBox(width: 8), + _chip(context, DateRangeFilter.thisMonth, 'This month'), + ], + ), + ); + } + + Widget _chip(BuildContext context, DateRangeFilter filter, String label) { + return FilterChip( + label: Text(label), + selected: selected == filter, + onSelected: (_) => onChanged(filter), + ); + } +} + +(DateTime, DateTime) dateRangeBounds(DateRangeFilter filter) { + final now = DateTime.now(); + switch (filter) { + case DateRangeFilter.today: + final start = DateTime(now.year, now.month, now.day); + return (start, start.add(const Duration(days: 1))); + case DateRangeFilter.thisWeek: + final monday = now.subtract(Duration(days: now.weekday - 1)); + final start = DateTime(monday.year, monday.month, monday.day); + return (start, start.add(const Duration(days: 7))); + case DateRangeFilter.thisMonth: + final start = DateTime(now.year, now.month); + return (start, DateTime(now.year, now.month + 1)); + } +} diff --git a/lib/features/entries/presentation/widgets/day_header.dart b/lib/features/entries/presentation/widgets/day_header.dart new file mode 100644 index 0000000..d3cdd64 --- /dev/null +++ b/lib/features/entries/presentation/widgets/day_header.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; + +class DayHeader extends StatelessWidget { + const DayHeader({ + super.key, + required this.date, + required this.totalDuration, + }); + + final DateTime date; + final Duration totalDuration; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), + child: Row( + children: [ + Text( + _label(date), + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + Text( + _fmt(totalDuration), + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + } + + String _label(DateTime d) { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final yesterday = today.subtract(const Duration(days: 1)); + final target = DateTime(d.year, d.month, d.day); + + if (target == today) return 'Today'; + if (target == yesterday) return 'Yesterday'; + + const weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + return '${weekdays[d.weekday - 1]}, ${d.day} ${months[d.month - 1]}'; + } + + String _fmt(Duration d) { + final h = d.inHours; + final m = d.inMinutes % 60; + if (h > 0) return '${h}h ${m}m'; + return '${m}m'; + } +} diff --git a/lib/features/entries/presentation/widgets/entry_form_sheet.dart b/lib/features/entries/presentation/widgets/entry_form_sheet.dart new file mode 100644 index 0000000..d0fdc3e --- /dev/null +++ b/lib/features/entries/presentation/widgets/entry_form_sheet.dart @@ -0,0 +1,246 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:timetrack/features/entries/domain/entries_provider.dart'; +import 'package:timetrack/features/entries/domain/time_entry.dart'; +import 'package:timetrack/features/projects/domain/project.dart'; +import 'package:timetrack/features/projects/domain/projects_provider.dart'; + +class EntryFormSheet extends ConsumerStatefulWidget { + const EntryFormSheet({super.key, this.existing}); + + final TimeEntry? existing; + + static Future show(BuildContext context, {TimeEntry? existing}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (_) => EntryFormSheet(existing: existing), + ); + } + + @override + ConsumerState createState() => _EntryFormSheetState(); +} + +class _EntryFormSheetState extends ConsumerState { + Project? _project; + late DateTime _startTime; + late DateTime _endTime; + final _noteController = TextEditingController(); + String? _error; + + @override + void initState() { + super.initState(); + final now = DateTime.now(); + _startTime = widget.existing?.startTime ?? now; + _endTime = widget.existing?.endTime ?? now; + _noteController.text = widget.existing?.note ?? ''; + } + + @override + void dispose() { + _noteController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final projectsAsync = ref.watch(activeProjectsProvider); + + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom + 16, + top: 16, + left: 16, + right: 16, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.existing == null ? 'Add Entry' : 'Edit Entry', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 16), + + // Project picker + projectsAsync.when( + data: (projects) => DropdownButtonFormField( + initialValue: _project ?? + (widget.existing != null + ? projects + .where((p) => p.id == widget.existing!.projectId) + .firstOrNull + : null), + items: projects + .map((p) => DropdownMenuItem( + value: p, + child: Row( + children: [ + CircleAvatar( + backgroundColor: Color(p.colorValue), + radius: 8, + ), + const SizedBox(width: 8), + Text(p.name), + ], + ), + )) + .toList(), + onChanged: (p) => setState(() => _project = p), + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'Project', + ), + ), + loading: () => const LinearProgressIndicator(), + error: (e, _) => Text('Error: $e'), + ), + const SizedBox(height: 12), + + // Start / End time + Row( + children: [ + Expanded( + child: _TimeField( + label: 'Start', + value: _startTime, + onChanged: (dt) => setState(() => _startTime = dt), + ), + ), + const SizedBox(width: 12), + Expanded( + child: _TimeField( + label: 'End', + value: _endTime, + onChanged: (dt) => setState(() => _endTime = dt), + ), + ), + ], + ), + const SizedBox(height: 12), + + // Note + TextField( + controller: _noteController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'Note (optional)', + ), + ), + + if (_error != null) ...[ + const SizedBox(height: 8), + Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), + ], + + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: _save, + child: const Text('Save'), + ), + ], + ), + ], + ), + ); + } + + Future _save() async { + if (_project == null) { + setState(() => _error = 'Please select a project.'); + return; + } + if (!_endTime.isAfter(_startTime)) { + setState(() => _error = 'End time must be after start time.'); + return; + } + + final duration = _endTime.difference(_startTime); + final notifier = ref.read(entriesNotifierProvider.notifier); + final now = DateTime.now(); + + if (widget.existing == null) { + await notifier.create(TimeEntry( + id: 0, + projectId: _project!.id, + startTime: _startTime, + endTime: _endTime, + durationSeconds: duration.inSeconds, + note: _noteController.text.trim().isEmpty + ? null + : _noteController.text.trim(), + createdAt: now, + )); + } else { + await notifier.updateEntry(widget.existing!.copyWith( + projectId: _project!.id, + startTime: _startTime, + endTime: _endTime, + durationSeconds: duration.inSeconds, + note: _noteController.text.trim().isEmpty + ? null + : _noteController.text.trim(), + )); + } + + if (mounted) Navigator.pop(context); + } +} + +class _TimeField extends StatelessWidget { + const _TimeField({ + required this.label, + required this.value, + required this.onChanged, + }); + + final String label; + final DateTime value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: () async { + final date = await showDatePicker( + context: context, + initialDate: value, + firstDate: DateTime(2020), + lastDate: DateTime.now().add(const Duration(days: 1)), + ); + if (date == null || !context.mounted) return; + final time = await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime(value), + ); + if (time == null) return; + onChanged(DateTime(date.year, date.month, date.day, time.hour, time.minute)); + }, + child: InputDecorator( + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: label, + ), + child: Text( + '${value.day.toString().padLeft(2, '0')}.${value.month.toString().padLeft(2, '0')} ' + '${value.hour.toString().padLeft(2, '0')}:${value.minute.toString().padLeft(2, '0')}', + ), + ), + ); + } +} diff --git a/lib/features/entries/presentation/widgets/entry_list_tile.dart b/lib/features/entries/presentation/widgets/entry_list_tile.dart new file mode 100644 index 0000000..b6708ee --- /dev/null +++ b/lib/features/entries/presentation/widgets/entry_list_tile.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; + +import 'package:timetrack/features/entries/domain/time_entry.dart'; + +class EntryListTile extends StatelessWidget { + const EntryListTile({ + super.key, + required this.entry, + required this.projectName, + required this.projectColor, + required this.onTap, + required this.onDelete, + }); + + final TimeEntry entry; + final String projectName; + final int projectColor; + final VoidCallback onTap; + final VoidCallback onDelete; + + @override + Widget build(BuildContext context) { + final dur = Duration(seconds: entry.durationSeconds ?? 0); + final start = _time(entry.startTime); + final end = entry.endTime != null ? _time(entry.endTime!) : '…'; + + return Dismissible( + key: Key('entry_${entry.id}'), + direction: DismissDirection.endToStart, + background: Container( + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 24), + color: Theme.of(context).colorScheme.errorContainer, + child: Icon( + Icons.delete_outline, + color: Theme.of(context).colorScheme.onErrorContainer, + ), + ), + confirmDismiss: (_) async => true, + onDismissed: (_) => onDelete(), + child: ListTile( + leading: CircleAvatar( + backgroundColor: Color(projectColor), + radius: 14, + ), + title: Text( + entry.note?.isNotEmpty == true ? entry.note! : projectName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text('$start – $end'), + trailing: Text( + _fmt(dur), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + onTap: onTap, + ), + ); + } + + String _time(DateTime dt) => + '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; + + String _fmt(Duration d) { + final h = d.inHours; + final m = d.inMinutes % 60; + if (h > 0) return '${h}h ${m}m'; + return '${m}m'; + } +} diff --git a/lib/features/projects/data/drift_projects_repository.dart b/lib/features/projects/data/drift_projects_repository.dart new file mode 100644 index 0000000..39a64d5 --- /dev/null +++ b/lib/features/projects/data/drift_projects_repository.dart @@ -0,0 +1,71 @@ +import 'package:drift/drift.dart' show Value; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:timetrack/core/database/app_database.dart' as db; +import 'package:timetrack/features/projects/data/project_mapper.dart'; +import 'package:timetrack/features/projects/data/projects_repository.dart'; +import 'package:timetrack/features/projects/domain/project.dart'; + +part 'drift_projects_repository.g.dart'; + +class DriftProjectsRepository implements ProjectsRepository { + DriftProjectsRepository(this._db); + + final db.AppDatabase _db; + + @override + Stream> watchAll() => _db.projectsDao + .watchAll() + .map((rows) => rows.map((r) => r.toDomain()).toList()); + + @override + Stream> watchActive() => _db.projectsDao + .watchActive() + .map((rows) => rows.map((r) => r.toDomain()).toList()); + + @override + Future getById(int id) async { + final row = await _db.projectsDao.getById(id); + return row?.toDomain(); + } + + @override + Future create({ + required String name, + required int colorValue, + String? description, + }) => + _db.projectsDao.insertProject( + db.ProjectsCompanion.insert( + name: name, + colorValue: colorValue, + description: Value(description), + ), + ); + + @override + Future update(Project project) => _db.projectsDao.updateProject( + db.ProjectsCompanion( + id: Value(project.id), + name: Value(project.name), + colorValue: Value(project.colorValue), + description: Value(project.description), + archivedAt: Value(project.archivedAt), + ), + ); + + @override + Future archive(int id) => _db.projectsDao.archiveProject(id); + + @override + Future unarchive(int id) => _db.projectsDao.unarchiveProject(id); + + @override + Future delete(int id) => _db.projectsDao.deleteProject(id); +} + +@riverpod +ProjectsRepository projectsRepository(ProjectsRepositoryRef ref) { + final database = ref.watch(db.appDatabaseProvider); + return DriftProjectsRepository(database); +} diff --git a/lib/features/projects/data/drift_projects_repository.g.dart b/lib/features/projects/data/drift_projects_repository.g.dart new file mode 100644 index 0000000..a0266d8 --- /dev/null +++ b/lib/features/projects/data/drift_projects_repository.g.dart @@ -0,0 +1,29 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'drift_projects_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$projectsRepositoryHash() => + r'dc98b1f51e4c8cad72dcd3bdd06083d5ea585865'; + +/// See also [projectsRepository]. +@ProviderFor(projectsRepository) +final projectsRepositoryProvider = + AutoDisposeProvider.internal( + projectsRepository, + name: r'projectsRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$projectsRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef ProjectsRepositoryRef = AutoDisposeProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/projects/data/project_mapper.dart b/lib/features/projects/data/project_mapper.dart new file mode 100644 index 0000000..db25e33 --- /dev/null +++ b/lib/features/projects/data/project_mapper.dart @@ -0,0 +1,24 @@ +import 'package:drift/drift.dart' show Value; + +import 'package:timetrack/core/database/app_database.dart' as db; +import 'package:timetrack/features/projects/domain/project.dart'; + +extension ProjectMapper on db.Project { + Project toDomain() => Project( + id: id, + name: name, + colorValue: colorValue, + description: description, + archivedAt: archivedAt, + createdAt: createdAt, + ); +} + +extension ProjectDomainMapper on Project { + db.ProjectsCompanion toInsertCompanion() => db.ProjectsCompanion.insert( + name: name, + colorValue: colorValue, + description: Value(description), + archivedAt: Value(archivedAt), + ); +} diff --git a/lib/features/projects/data/projects_repository.dart b/lib/features/projects/data/projects_repository.dart new file mode 100644 index 0000000..46c79d9 --- /dev/null +++ b/lib/features/projects/data/projects_repository.dart @@ -0,0 +1,16 @@ +import 'package:timetrack/features/projects/domain/project.dart'; + +abstract class ProjectsRepository { + Stream> watchAll(); + Stream> watchActive(); + Future getById(int id); + Future create({ + required String name, + required int colorValue, + String? description, + }); + Future update(Project project); + Future archive(int id); + Future unarchive(int id); + Future delete(int id); +} diff --git a/lib/features/projects/domain/project.dart b/lib/features/projects/domain/project.dart new file mode 100644 index 0000000..525fee7 --- /dev/null +++ b/lib/features/projects/domain/project.dart @@ -0,0 +1,19 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'project.freezed.dart'; +part 'project.g.dart'; + +@freezed +class Project with _$Project { + const factory Project({ + required int id, + required String name, + required int colorValue, + String? description, + DateTime? archivedAt, + required DateTime createdAt, + }) = _Project; + + factory Project.fromJson(Map json) => + _$ProjectFromJson(json); +} diff --git a/lib/features/projects/domain/project.freezed.dart b/lib/features/projects/domain/project.freezed.dart new file mode 100644 index 0000000..b86f856 --- /dev/null +++ b/lib/features/projects/domain/project.freezed.dart @@ -0,0 +1,286 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'project.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models', +); + +Project _$ProjectFromJson(Map json) { + return _Project.fromJson(json); +} + +/// @nodoc +mixin _$Project { + int get id => throw _privateConstructorUsedError; + String get name => throw _privateConstructorUsedError; + int get colorValue => throw _privateConstructorUsedError; + String? get description => throw _privateConstructorUsedError; + DateTime? get archivedAt => throw _privateConstructorUsedError; + DateTime get createdAt => throw _privateConstructorUsedError; + + /// Serializes this Project to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of Project + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $ProjectCopyWith get copyWith => throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $ProjectCopyWith<$Res> { + factory $ProjectCopyWith(Project value, $Res Function(Project) then) = + _$ProjectCopyWithImpl<$Res, Project>; + @useResult + $Res call({ + int id, + String name, + int colorValue, + String? description, + DateTime? archivedAt, + DateTime createdAt, + }); +} + +/// @nodoc +class _$ProjectCopyWithImpl<$Res, $Val extends Project> + implements $ProjectCopyWith<$Res> { + _$ProjectCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of Project + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? name = null, + Object? colorValue = null, + Object? description = freezed, + Object? archivedAt = freezed, + Object? createdAt = null, + }) { + return _then( + _value.copyWith( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as int, + name: null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + colorValue: null == colorValue + ? _value.colorValue + : colorValue // ignore: cast_nullable_to_non_nullable + as int, + description: freezed == description + ? _value.description + : description // ignore: cast_nullable_to_non_nullable + as String?, + archivedAt: freezed == archivedAt + ? _value.archivedAt + : archivedAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + createdAt: null == createdAt + ? _value.createdAt + : createdAt // ignore: cast_nullable_to_non_nullable + as DateTime, + ) + as $Val, + ); + } +} + +/// @nodoc +abstract class _$$ProjectImplCopyWith<$Res> implements $ProjectCopyWith<$Res> { + factory _$$ProjectImplCopyWith( + _$ProjectImpl value, + $Res Function(_$ProjectImpl) then, + ) = __$$ProjectImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({ + int id, + String name, + int colorValue, + String? description, + DateTime? archivedAt, + DateTime createdAt, + }); +} + +/// @nodoc +class __$$ProjectImplCopyWithImpl<$Res> + extends _$ProjectCopyWithImpl<$Res, _$ProjectImpl> + implements _$$ProjectImplCopyWith<$Res> { + __$$ProjectImplCopyWithImpl( + _$ProjectImpl _value, + $Res Function(_$ProjectImpl) _then, + ) : super(_value, _then); + + /// Create a copy of Project + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? name = null, + Object? colorValue = null, + Object? description = freezed, + Object? archivedAt = freezed, + Object? createdAt = null, + }) { + return _then( + _$ProjectImpl( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as int, + name: null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + colorValue: null == colorValue + ? _value.colorValue + : colorValue // ignore: cast_nullable_to_non_nullable + as int, + description: freezed == description + ? _value.description + : description // ignore: cast_nullable_to_non_nullable + as String?, + archivedAt: freezed == archivedAt + ? _value.archivedAt + : archivedAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + createdAt: null == createdAt + ? _value.createdAt + : createdAt // ignore: cast_nullable_to_non_nullable + as DateTime, + ), + ); + } +} + +/// @nodoc +@JsonSerializable() +class _$ProjectImpl implements _Project { + const _$ProjectImpl({ + required this.id, + required this.name, + required this.colorValue, + this.description, + this.archivedAt, + required this.createdAt, + }); + + factory _$ProjectImpl.fromJson(Map json) => + _$$ProjectImplFromJson(json); + + @override + final int id; + @override + final String name; + @override + final int colorValue; + @override + final String? description; + @override + final DateTime? archivedAt; + @override + final DateTime createdAt; + + @override + String toString() { + return 'Project(id: $id, name: $name, colorValue: $colorValue, description: $description, archivedAt: $archivedAt, createdAt: $createdAt)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$ProjectImpl && + (identical(other.id, id) || other.id == id) && + (identical(other.name, name) || other.name == name) && + (identical(other.colorValue, colorValue) || + other.colorValue == colorValue) && + (identical(other.description, description) || + other.description == description) && + (identical(other.archivedAt, archivedAt) || + other.archivedAt == archivedAt) && + (identical(other.createdAt, createdAt) || + other.createdAt == createdAt)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, + id, + name, + colorValue, + description, + archivedAt, + createdAt, + ); + + /// Create a copy of Project + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$ProjectImplCopyWith<_$ProjectImpl> get copyWith => + __$$ProjectImplCopyWithImpl<_$ProjectImpl>(this, _$identity); + + @override + Map toJson() { + return _$$ProjectImplToJson(this); + } +} + +abstract class _Project implements Project { + const factory _Project({ + required final int id, + required final String name, + required final int colorValue, + final String? description, + final DateTime? archivedAt, + required final DateTime createdAt, + }) = _$ProjectImpl; + + factory _Project.fromJson(Map json) = _$ProjectImpl.fromJson; + + @override + int get id; + @override + String get name; + @override + int get colorValue; + @override + String? get description; + @override + DateTime? get archivedAt; + @override + DateTime get createdAt; + + /// Create a copy of Project + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$ProjectImplCopyWith<_$ProjectImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/features/projects/domain/project.g.dart b/lib/features/projects/domain/project.g.dart new file mode 100644 index 0000000..857d79b --- /dev/null +++ b/lib/features/projects/domain/project.g.dart @@ -0,0 +1,29 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'project.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$ProjectImpl _$$ProjectImplFromJson(Map json) => + _$ProjectImpl( + id: (json['id'] as num).toInt(), + name: json['name'] as String, + colorValue: (json['colorValue'] as num).toInt(), + description: json['description'] as String?, + archivedAt: json['archivedAt'] == null + ? null + : DateTime.parse(json['archivedAt'] as String), + createdAt: DateTime.parse(json['createdAt'] as String), + ); + +Map _$$ProjectImplToJson(_$ProjectImpl instance) => + { + 'id': instance.id, + 'name': instance.name, + 'colorValue': instance.colorValue, + 'description': instance.description, + 'archivedAt': instance.archivedAt?.toIso8601String(), + 'createdAt': instance.createdAt.toIso8601String(), + }; diff --git a/lib/features/projects/domain/projects_provider.dart b/lib/features/projects/domain/projects_provider.dart new file mode 100644 index 0000000..a0aa447 --- /dev/null +++ b/lib/features/projects/domain/projects_provider.dart @@ -0,0 +1,39 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:timetrack/features/projects/data/drift_projects_repository.dart'; +import 'package:timetrack/features/projects/data/projects_repository.dart'; +import 'package:timetrack/features/projects/domain/project.dart'; + +part 'projects_provider.g.dart'; + +@riverpod +Stream> activeProjects(ActiveProjectsRef ref) => + ref.watch(projectsRepositoryProvider).watchActive(); + +@riverpod +Stream> allProjects(AllProjectsRef ref) => + ref.watch(projectsRepositoryProvider).watchAll(); + +@riverpod +class ProjectsNotifier extends _$ProjectsNotifier { + @override + Future build() async {} + + ProjectsRepository get _repo => ref.read(projectsRepositoryProvider); + + Future create({ + required String name, + required int colorValue, + String? description, + }) async { + await _repo.create(name: name, colorValue: colorValue, description: description); + } + + Future updateProject(Project project) => _repo.update(project); + + Future archive(int id) => _repo.archive(id); + + Future unarchive(int id) => _repo.unarchive(id); + + Future delete(int id) => _repo.delete(id); +} diff --git a/lib/features/projects/domain/projects_provider.g.dart b/lib/features/projects/domain/projects_provider.g.dart new file mode 100644 index 0000000..8c7f060 --- /dev/null +++ b/lib/features/projects/domain/projects_provider.g.dart @@ -0,0 +1,61 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'projects_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$activeProjectsHash() => r'59720efab7adc1685e745469513dd958bd189302'; + +/// See also [activeProjects]. +@ProviderFor(activeProjects) +final activeProjectsProvider = + AutoDisposeStreamProvider>.internal( + activeProjects, + name: r'activeProjectsProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$activeProjectsHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef ActiveProjectsRef = AutoDisposeStreamProviderRef>; +String _$allProjectsHash() => r'7601a9a263c52684e2f42d7454251b8686940ebf'; + +/// See also [allProjects]. +@ProviderFor(allProjects) +final allProjectsProvider = AutoDisposeStreamProvider>.internal( + allProjects, + name: r'allProjectsProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$allProjectsHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef AllProjectsRef = AutoDisposeStreamProviderRef>; +String _$projectsNotifierHash() => r'8d6bc219f1f35b5b428c9dfa705168a9f1a1322d'; + +/// See also [ProjectsNotifier]. +@ProviderFor(ProjectsNotifier) +final projectsNotifierProvider = + AutoDisposeAsyncNotifierProvider.internal( + ProjectsNotifier.new, + name: r'projectsNotifierProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$projectsNotifierHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$ProjectsNotifier = AutoDisposeAsyncNotifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/projects/presentation/projects_screen.dart b/lib/features/projects/presentation/projects_screen.dart new file mode 100644 index 0000000..d19372d --- /dev/null +++ b/lib/features/projects/presentation/projects_screen.dart @@ -0,0 +1,160 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:timetrack/features/projects/domain/project.dart'; +import 'package:timetrack/features/projects/domain/projects_provider.dart'; +import 'package:timetrack/features/projects/presentation/widgets/project_form_sheet.dart'; +import 'package:timetrack/features/projects/presentation/widgets/project_list_tile.dart'; + +class ProjectsScreen extends ConsumerStatefulWidget { + const ProjectsScreen({super.key}); + + @override + ConsumerState createState() => _ProjectsScreenState(); +} + +class _ProjectsScreenState extends ConsumerState + with SingleTickerProviderStateMixin { + late TabController _tabController; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 2, vsync: this); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Projects'), + actions: [ + IconButton( + icon: const Icon(Icons.add), + tooltip: 'New project', + onPressed: () => ProjectFormSheet.show(context), + ), + ], + bottom: TabBar( + controller: _tabController, + tabs: const [ + Tab(text: 'Active'), + Tab(text: 'Archived'), + ], + ), + ), + body: TabBarView( + controller: _tabController, + children: const [ + _ProjectList(archived: false), + _ProjectList(archived: true), + ], + ), + ); + } +} + +class _ProjectList extends ConsumerWidget { + const _ProjectList({required this.archived}); + + final bool archived; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final projectsAsync = archived + ? ref.watch(allProjectsProvider) + : ref.watch(activeProjectsProvider); + + return projectsAsync.when( + data: (allProjects) { + final projects = archived + ? allProjects.where((p) => p.archivedAt != null).toList() + : allProjects; + + if (projects.isEmpty) { + return Center( + key: Key(archived ? 'archived_empty' : 'active_empty'), + child: Text( + archived ? 'No archived projects.' : 'No projects yet.', + ), + ); + } + + return ListView.builder( + itemCount: projects.length, + itemBuilder: (context, index) { + final project = projects[index]; + return ProjectListTile( + key: Key('project_tile_${project.id}'), + project: project, + totalDuration: Duration.zero, // TODO: wire up from reports repo + onTap: () => ProjectFormSheet.show(context, existing: project), + onArchive: () => _toggleArchive(context, ref, project), + onDelete: () => _confirmDelete(context, ref, project), + ); + }, + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('Error: $e')), + ); + } + + void _toggleArchive(BuildContext context, WidgetRef ref, Project project) { + final notifier = ref.read(projectsNotifierProvider.notifier); + if (project.archivedAt == null) { + notifier.archive(project.id); + } else { + notifier.unarchive(project.id); + } + } + + Future _confirmDelete( + BuildContext context, + WidgetRef ref, + Project project, + ) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Delete project?'), + content: Text( + 'Delete "${project.name}"? This cannot be undone.\n\n' + 'Projects with existing entries cannot be deleted.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + style: FilledButton.styleFrom( + backgroundColor: Theme.of(ctx).colorScheme.error, + ), + child: const Text('Delete'), + ), + ], + ), + ); + if (confirmed == true) { + try { + await ref.read(projectsNotifierProvider.notifier).delete(project.id); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Cannot delete — project has time entries.'), + ), + ); + } + } + } + } +} diff --git a/lib/features/projects/presentation/widgets/color_picker_row.dart b/lib/features/projects/presentation/widgets/color_picker_row.dart new file mode 100644 index 0000000..292be2b --- /dev/null +++ b/lib/features/projects/presentation/widgets/color_picker_row.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; + +class ColorPickerRow extends StatelessWidget { + const ColorPickerRow({ + super.key, + required this.selected, + required this.onChanged, + }); + + final int selected; + final ValueChanged onChanged; + + static const presetColors = [ + 0xFF2563EB, // blue + 0xFF16A34A, // green + 0xFFDC2626, // red + 0xFFD97706, // amber + 0xFF7C3AED, // violet + 0xFFDB2777, // pink + 0xFF0891B2, // cyan + 0xFF65A30D, // lime + 0xFFEA580C, // orange + 0xFF475569, // slate + 0xFF059669, // emerald + 0xFFB45309, // yellow-brown + ]; + + @override + Widget build(BuildContext context) { + return Wrap( + spacing: 10, + runSpacing: 10, + children: presetColors.map((color) { + final isSelected = color == selected; + return GestureDetector( + onTap: () => onChanged(color), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + width: 36, + height: 36, + decoration: BoxDecoration( + color: Color(color), + shape: BoxShape.circle, + border: isSelected + ? Border.all( + color: Theme.of(context).colorScheme.onSurface, + width: 3, + ) + : null, + boxShadow: isSelected + ? [ + BoxShadow( + color: Color(color).withValues(alpha: 0.5), + blurRadius: 6, + spreadRadius: 1, + ), + ] + : null, + ), + child: isSelected + ? const Icon(Icons.check, color: Colors.white, size: 18) + : null, + ), + ); + }).toList(), + ); + } +} diff --git a/lib/features/projects/presentation/widgets/project_form_sheet.dart b/lib/features/projects/presentation/widgets/project_form_sheet.dart new file mode 100644 index 0000000..4457ee8 --- /dev/null +++ b/lib/features/projects/presentation/widgets/project_form_sheet.dart @@ -0,0 +1,144 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:timetrack/features/projects/domain/project.dart'; +import 'package:timetrack/features/projects/domain/projects_provider.dart'; +import 'package:timetrack/features/projects/presentation/widgets/color_picker_row.dart'; + +class ProjectFormSheet extends ConsumerStatefulWidget { + const ProjectFormSheet({super.key, this.existing}); + + final Project? existing; + + static Future show(BuildContext context, {Project? existing}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (_) => ProjectFormSheet(existing: existing), + ); + } + + @override + ConsumerState createState() => _ProjectFormSheetState(); +} + +class _ProjectFormSheetState extends ConsumerState { + late int _color; + final _nameController = TextEditingController(); + final _descController = TextEditingController(); + String? _error; + + @override + void initState() { + super.initState(); + _color = widget.existing?.colorValue ?? ColorPickerRow.presetColors.first; + _nameController.text = widget.existing?.name ?? ''; + _descController.text = widget.existing?.description ?? ''; + } + + @override + void dispose() { + _nameController.dispose(); + _descController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom + 16, + top: 16, + left: 16, + right: 16, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.existing == null ? 'New Project' : 'Edit Project', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 16), + TextField( + key: const Key('project_name_field'), + controller: _nameController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'Name', + ), + textCapitalization: TextCapitalization.words, + ), + const SizedBox(height: 12), + TextField( + controller: _descController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'Description (optional)', + ), + ), + const SizedBox(height: 16), + Text('Color', style: Theme.of(context).textTheme.labelLarge), + const SizedBox(height: 8), + ColorPickerRow( + selected: _color, + onChanged: (c) => setState(() => _color = c), + ), + if (_error != null) ...[ + const SizedBox(height: 8), + Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), + ], + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + const SizedBox(width: 8), + FilledButton( + key: const Key('project_save_button'), + onPressed: _save, + child: const Text('Save'), + ), + ], + ), + ], + ), + ); + } + + Future _save() async { + final name = _nameController.text.trim(); + if (name.isEmpty) { + setState(() => _error = 'Name is required.'); + return; + } + + final notifier = ref.read(projectsNotifierProvider.notifier); + if (widget.existing == null) { + await notifier.create( + name: name, + colorValue: _color, + description: _descController.text.trim().isEmpty + ? null + : _descController.text.trim(), + ); + } else { + await notifier.updateProject(widget.existing!.copyWith( + name: name, + colorValue: _color, + description: _descController.text.trim().isEmpty + ? null + : _descController.text.trim(), + )); + } + + if (mounted) Navigator.pop(context); + } +} diff --git a/lib/features/projects/presentation/widgets/project_list_tile.dart b/lib/features/projects/presentation/widgets/project_list_tile.dart new file mode 100644 index 0000000..98655dc --- /dev/null +++ b/lib/features/projects/presentation/widgets/project_list_tile.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; + +import 'package:timetrack/features/projects/domain/project.dart'; + +class ProjectListTile extends StatelessWidget { + const ProjectListTile({ + super.key, + required this.project, + required this.totalDuration, + required this.onTap, + required this.onArchive, + required this.onDelete, + }); + + final Project project; + final Duration totalDuration; + final VoidCallback onTap; + final VoidCallback onArchive; + final VoidCallback onDelete; + + @override + Widget build(BuildContext context) { + final h = totalDuration.inHours; + final m = totalDuration.inMinutes % 60; + final durationLabel = h > 0 ? '${h}h ${m}m' : '${m}m'; + + return ListTile( + leading: CircleAvatar( + backgroundColor: Color(project.colorValue), + radius: 18, + child: Text( + project.name[0].toUpperCase(), + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold), + ), + ), + title: Text(project.name), + subtitle: Text(durationLabel), + trailing: PopupMenuButton<_Action>( + onSelected: (action) { + switch (action) { + case _Action.edit: + onTap(); + break; + case _Action.archive: + onArchive(); + break; + case _Action.delete: + onDelete(); + break; + } + }, + itemBuilder: (context) => [ + const PopupMenuItem(value: _Action.edit, child: Text('Edit')), + PopupMenuItem( + value: _Action.archive, + child: Text(project.archivedAt == null ? 'Archive' : 'Unarchive'), + ), + const PopupMenuItem( + value: _Action.delete, + child: Text('Delete', style: TextStyle(color: Colors.red)), + ), + ], + ), + onTap: onTap, + ); + } +} + +enum _Action { edit, archive, delete } diff --git a/lib/features/reports/data/drift_reports_repository.dart b/lib/features/reports/data/drift_reports_repository.dart new file mode 100644 index 0000000..87e1f0f --- /dev/null +++ b/lib/features/reports/data/drift_reports_repository.dart @@ -0,0 +1,98 @@ +import 'package:drift/drift.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:timetrack/core/database/app_database.dart' as db; +import 'package:timetrack/features/reports/data/reports_repository.dart'; + +part 'drift_reports_repository.g.dart'; + +class DriftReportsRepository implements ReportsRepository { + DriftReportsRepository(this._db); + + final db.AppDatabase _db; + + @override + Future getTotalDuration( + DateTime from, + DateTime to, { + int? projectId, + }) async { + final sum = _db.timeEntries.durationSeconds.sum(); + final query = _db.selectOnly(_db.timeEntries) + ..addColumns([sum]) + ..where(_db.timeEntries.startTime.isBiggerOrEqualValue(from) & + _db.timeEntries.startTime.isSmallerThanValue(to) & + _db.timeEntries.endTime.isNotNull()); + + if (projectId != null) { + query.where(_db.timeEntries.projectId.equals(projectId)); + } + + final row = await query.getSingleOrNull(); + return Duration(seconds: row?.read(sum) ?? 0); + } + + @override + Future> getDurationByProject( + DateTime from, + DateTime to, + ) async { + final sum = _db.timeEntries.durationSeconds.sum(); + final query = _db.selectOnly(_db.timeEntries) + ..addColumns([_db.timeEntries.projectId, sum]) + ..where(_db.timeEntries.startTime.isBiggerOrEqualValue(from) & + _db.timeEntries.startTime.isSmallerThanValue(to) & + _db.timeEntries.endTime.isNotNull()) + ..groupBy([_db.timeEntries.projectId]); + + final rows = await query.get(); + return { + for (final row in rows) + row.read(_db.timeEntries.projectId)!: + Duration(seconds: row.read(sum) ?? 0), + }; + } + + @override + Future> getDurationByWeekday(DateTime weekStart) async { + final weekEnd = weekStart.add(const Duration(days: 7)); + final entries = await (_db.select(_db.timeEntries) + ..where((t) => + t.startTime.isBiggerOrEqualValue(weekStart) & + t.startTime.isSmallerThanValue(weekEnd) & + t.endTime.isNotNull())) + .get(); + + final result = {for (var i = 0; i < 7; i++) i: 0}; + for (final entry in entries) { + // Mon=1…Sun=7 → 0-indexed Mon=0…Sun=6 + final day = (entry.startTime.weekday - 1) % 7; + result[day] = (result[day] ?? 0) + (entry.durationSeconds ?? 0); + } + return result.map((k, v) => MapEntry(k, Duration(seconds: v))); + } + + @override + Future> getDurationByDay(DateTime monthStart) async { + final monthEnd = DateTime(monthStart.year, monthStart.month + 1); + final entries = await (_db.select(_db.timeEntries) + ..where((t) => + t.startTime.isBiggerOrEqualValue(monthStart) & + t.startTime.isSmallerThanValue(monthEnd) & + t.endTime.isNotNull())) + .get(); + + final result = {}; + for (final entry in entries) { + final day = entry.startTime.day; + result[day] = (result[day] ?? 0) + (entry.durationSeconds ?? 0); + } + return result.map((k, v) => MapEntry(k, Duration(seconds: v))); + } +} + +@riverpod +ReportsRepository reportsRepository(ReportsRepositoryRef ref) { + final database = ref.watch(db.appDatabaseProvider); + return DriftReportsRepository(database); +} diff --git a/lib/features/reports/data/drift_reports_repository.g.dart b/lib/features/reports/data/drift_reports_repository.g.dart new file mode 100644 index 0000000..66029e4 --- /dev/null +++ b/lib/features/reports/data/drift_reports_repository.g.dart @@ -0,0 +1,28 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'drift_reports_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$reportsRepositoryHash() => r'98008c0962c9e7821ff4e17f5513431a75a5effc'; + +/// See also [reportsRepository]. +@ProviderFor(reportsRepository) +final reportsRepositoryProvider = + AutoDisposeProvider.internal( + reportsRepository, + name: r'reportsRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$reportsRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef ReportsRepositoryRef = AutoDisposeProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/reports/data/reports_repository.dart b/lib/features/reports/data/reports_repository.dart new file mode 100644 index 0000000..45e5572 --- /dev/null +++ b/lib/features/reports/data/reports_repository.dart @@ -0,0 +1,15 @@ +abstract class ReportsRepository { + Future getTotalDuration( + DateTime from, + DateTime to, { + int? projectId, + }); + + Future> getDurationByProject(DateTime from, DateTime to); + + /// Returns duration per weekday index (1=Mon … 7=Sun, SQLite strftime %w: 0=Sun) + Future> getDurationByWeekday(DateTime weekStart); + + /// Returns duration per day-of-month + Future> getDurationByDay(DateTime monthStart); +} diff --git a/lib/features/reports/domain/report_data.dart b/lib/features/reports/domain/report_data.dart new file mode 100644 index 0000000..1e6af19 --- /dev/null +++ b/lib/features/reports/domain/report_data.dart @@ -0,0 +1,16 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'report_data.freezed.dart'; + +enum ReportPeriod { day, week, month } + +@freezed +class ReportData with _$ReportData { + const factory ReportData({ + required Duration totalDuration, + required Map durationByProject, + @Default([]) List chartValues, + required DateTime periodStart, + required DateTime periodEnd, + }) = _ReportData; +} diff --git a/lib/features/reports/domain/report_data.freezed.dart b/lib/features/reports/domain/report_data.freezed.dart new file mode 100644 index 0000000..85179c5 --- /dev/null +++ b/lib/features/reports/domain/report_data.freezed.dart @@ -0,0 +1,272 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'report_data.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models', +); + +/// @nodoc +mixin _$ReportData { + Duration get totalDuration => throw _privateConstructorUsedError; + Map get durationByProject => + throw _privateConstructorUsedError; + List get chartValues => throw _privateConstructorUsedError; + DateTime get periodStart => throw _privateConstructorUsedError; + DateTime get periodEnd => throw _privateConstructorUsedError; + + /// Create a copy of ReportData + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $ReportDataCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $ReportDataCopyWith<$Res> { + factory $ReportDataCopyWith( + ReportData value, + $Res Function(ReportData) then, + ) = _$ReportDataCopyWithImpl<$Res, ReportData>; + @useResult + $Res call({ + Duration totalDuration, + Map durationByProject, + List chartValues, + DateTime periodStart, + DateTime periodEnd, + }); +} + +/// @nodoc +class _$ReportDataCopyWithImpl<$Res, $Val extends ReportData> + implements $ReportDataCopyWith<$Res> { + _$ReportDataCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of ReportData + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? totalDuration = null, + Object? durationByProject = null, + Object? chartValues = null, + Object? periodStart = null, + Object? periodEnd = null, + }) { + return _then( + _value.copyWith( + totalDuration: null == totalDuration + ? _value.totalDuration + : totalDuration // ignore: cast_nullable_to_non_nullable + as Duration, + durationByProject: null == durationByProject + ? _value.durationByProject + : durationByProject // ignore: cast_nullable_to_non_nullable + as Map, + chartValues: null == chartValues + ? _value.chartValues + : chartValues // ignore: cast_nullable_to_non_nullable + as List, + periodStart: null == periodStart + ? _value.periodStart + : periodStart // ignore: cast_nullable_to_non_nullable + as DateTime, + periodEnd: null == periodEnd + ? _value.periodEnd + : periodEnd // ignore: cast_nullable_to_non_nullable + as DateTime, + ) + as $Val, + ); + } +} + +/// @nodoc +abstract class _$$ReportDataImplCopyWith<$Res> + implements $ReportDataCopyWith<$Res> { + factory _$$ReportDataImplCopyWith( + _$ReportDataImpl value, + $Res Function(_$ReportDataImpl) then, + ) = __$$ReportDataImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({ + Duration totalDuration, + Map durationByProject, + List chartValues, + DateTime periodStart, + DateTime periodEnd, + }); +} + +/// @nodoc +class __$$ReportDataImplCopyWithImpl<$Res> + extends _$ReportDataCopyWithImpl<$Res, _$ReportDataImpl> + implements _$$ReportDataImplCopyWith<$Res> { + __$$ReportDataImplCopyWithImpl( + _$ReportDataImpl _value, + $Res Function(_$ReportDataImpl) _then, + ) : super(_value, _then); + + /// Create a copy of ReportData + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? totalDuration = null, + Object? durationByProject = null, + Object? chartValues = null, + Object? periodStart = null, + Object? periodEnd = null, + }) { + return _then( + _$ReportDataImpl( + totalDuration: null == totalDuration + ? _value.totalDuration + : totalDuration // ignore: cast_nullable_to_non_nullable + as Duration, + durationByProject: null == durationByProject + ? _value._durationByProject + : durationByProject // ignore: cast_nullable_to_non_nullable + as Map, + chartValues: null == chartValues + ? _value._chartValues + : chartValues // ignore: cast_nullable_to_non_nullable + as List, + periodStart: null == periodStart + ? _value.periodStart + : periodStart // ignore: cast_nullable_to_non_nullable + as DateTime, + periodEnd: null == periodEnd + ? _value.periodEnd + : periodEnd // ignore: cast_nullable_to_non_nullable + as DateTime, + ), + ); + } +} + +/// @nodoc + +class _$ReportDataImpl implements _ReportData { + const _$ReportDataImpl({ + required this.totalDuration, + required final Map durationByProject, + final List chartValues = const [], + required this.periodStart, + required this.periodEnd, + }) : _durationByProject = durationByProject, + _chartValues = chartValues; + + @override + final Duration totalDuration; + final Map _durationByProject; + @override + Map get durationByProject { + if (_durationByProject is EqualUnmodifiableMapView) + return _durationByProject; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(_durationByProject); + } + + final List _chartValues; + @override + @JsonKey() + List get chartValues { + if (_chartValues is EqualUnmodifiableListView) return _chartValues; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_chartValues); + } + + @override + final DateTime periodStart; + @override + final DateTime periodEnd; + + @override + String toString() { + return 'ReportData(totalDuration: $totalDuration, durationByProject: $durationByProject, chartValues: $chartValues, periodStart: $periodStart, periodEnd: $periodEnd)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$ReportDataImpl && + (identical(other.totalDuration, totalDuration) || + other.totalDuration == totalDuration) && + const DeepCollectionEquality().equals( + other._durationByProject, + _durationByProject, + ) && + const DeepCollectionEquality().equals( + other._chartValues, + _chartValues, + ) && + (identical(other.periodStart, periodStart) || + other.periodStart == periodStart) && + (identical(other.periodEnd, periodEnd) || + other.periodEnd == periodEnd)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + totalDuration, + const DeepCollectionEquality().hash(_durationByProject), + const DeepCollectionEquality().hash(_chartValues), + periodStart, + periodEnd, + ); + + /// Create a copy of ReportData + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$ReportDataImplCopyWith<_$ReportDataImpl> get copyWith => + __$$ReportDataImplCopyWithImpl<_$ReportDataImpl>(this, _$identity); +} + +abstract class _ReportData implements ReportData { + const factory _ReportData({ + required final Duration totalDuration, + required final Map durationByProject, + final List chartValues, + required final DateTime periodStart, + required final DateTime periodEnd, + }) = _$ReportDataImpl; + + @override + Duration get totalDuration; + @override + Map get durationByProject; + @override + List get chartValues; + @override + DateTime get periodStart; + @override + DateTime get periodEnd; + + /// Create a copy of ReportData + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$ReportDataImplCopyWith<_$ReportDataImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/features/reports/domain/reports_provider.dart b/lib/features/reports/domain/reports_provider.dart new file mode 100644 index 0000000..2006319 --- /dev/null +++ b/lib/features/reports/domain/reports_provider.dart @@ -0,0 +1,101 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:timetrack/features/reports/data/drift_reports_repository.dart'; +import 'package:timetrack/features/reports/data/reports_repository.dart'; +import 'package:timetrack/features/reports/domain/report_data.dart'; + +part 'reports_provider.g.dart'; + +@riverpod +class ReportsNotifier extends _$ReportsNotifier { + @override + Future build() => _loadData(); + + ReportPeriod _period = ReportPeriod.week; + DateTime _anchor = DateTime.now(); + int? _filterProjectId; + + ReportsRepository get _repo => ref.read(reportsRepositoryProvider); + + ReportPeriod get period => _period; + + void selectPeriod(ReportPeriod period) { + _period = period; + _anchor = DateTime.now(); + ref.invalidateSelf(); + } + + void goToPrevious() { + _anchor = _shift(-1); + ref.invalidateSelf(); + } + + void goToNext() { + _anchor = _shift(1); + ref.invalidateSelf(); + } + + void filterByProject(int? projectId) { + _filterProjectId = projectId; + ref.invalidateSelf(); + } + + DateTime _shift(int direction) { + switch (_period) { + case ReportPeriod.day: + return _anchor.add(Duration(days: direction)); + case ReportPeriod.week: + return _anchor.add(Duration(days: 7 * direction)); + case ReportPeriod.month: + return DateTime(_anchor.year, _anchor.month + direction); + } + } + + (DateTime, DateTime) _periodBounds() { + switch (_period) { + case ReportPeriod.day: + final start = DateTime(_anchor.year, _anchor.month, _anchor.day); + return (start, start.add(const Duration(days: 1))); + case ReportPeriod.week: + final monday = _anchor.subtract(Duration(days: _anchor.weekday - 1)); + final start = DateTime(monday.year, monday.month, monday.day); + return (start, start.add(const Duration(days: 7))); + case ReportPeriod.month: + final start = DateTime(_anchor.year, _anchor.month); + return (start, DateTime(_anchor.year, _anchor.month + 1)); + } + } + + Future _loadData() async { + final (from, to) = _periodBounds(); + final total = await _repo.getTotalDuration(from, to, projectId: _filterProjectId); + final byProject = await _repo.getDurationByProject(from, to); + + final List chartValues; + switch (_period) { + case ReportPeriod.day: + chartValues = List.filled(24, 0.0); + break; + case ReportPeriod.week: + final byDay = await _repo.getDurationByWeekday(from); + chartValues = List.generate(7, (i) => (byDay[i]?.inMinutes ?? 0).toDouble()); + break; + case ReportPeriod.month: + final byDay = await _repo.getDurationByDay(from); + final daysInMonth = to.difference(from).inDays; + chartValues = List.generate( + daysInMonth, + (i) => (byDay[i + 1]?.inMinutes ?? 0).toDouble(), + ); + break; + } + + return ReportData( + totalDuration: total, + durationByProject: byProject, + chartValues: chartValues, + periodStart: from, + periodEnd: to, + ); + } +} diff --git a/lib/features/reports/domain/reports_provider.g.dart b/lib/features/reports/domain/reports_provider.g.dart new file mode 100644 index 0000000..cbbabe4 --- /dev/null +++ b/lib/features/reports/domain/reports_provider.g.dart @@ -0,0 +1,26 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'reports_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$reportsNotifierHash() => r'f6fb51853ab5bcf77bee52385b3286979b6bc1c8'; + +/// See also [ReportsNotifier]. +@ProviderFor(ReportsNotifier) +final reportsNotifierProvider = + AutoDisposeAsyncNotifierProvider.internal( + ReportsNotifier.new, + name: r'reportsNotifierProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$reportsNotifierHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$ReportsNotifier = AutoDisposeAsyncNotifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/reports/presentation/reports_screen.dart b/lib/features/reports/presentation/reports_screen.dart new file mode 100644 index 0000000..b890169 --- /dev/null +++ b/lib/features/reports/presentation/reports_screen.dart @@ -0,0 +1,142 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:timetrack/features/projects/domain/projects_provider.dart'; +import 'package:timetrack/features/reports/domain/report_data.dart'; +import 'package:timetrack/features/reports/domain/reports_provider.dart'; +import 'package:timetrack/features/reports/presentation/widgets/duration_bar_chart.dart'; +import 'package:timetrack/features/reports/presentation/widgets/period_navigator.dart'; +import 'package:timetrack/features/reports/presentation/widgets/project_breakdown_list.dart'; +import 'package:timetrack/features/reports/presentation/widgets/summary_header.dart'; + +class ReportsScreen extends ConsumerWidget { + const ReportsScreen({super.key}); + + static const _weekLabels = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + static const _dayLabels = [ + '0', '2', '4', '6', '8', '10', '12', '14', '16', '18', '20', '22', + ]; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final notifier = ref.read(reportsNotifierProvider.notifier); + final reportAsync = ref.watch(reportsNotifierProvider); + final projectsAsync = ref.watch(allProjectsProvider); + + return Scaffold( + appBar: AppBar( + title: const Text('Reports'), + bottom: PreferredSize( + preferredSize: const Size.fromHeight(48), + child: _PeriodTabs( + current: notifier.period, + onChanged: notifier.selectPeriod, + ), + ), + ), + body: reportAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('Error: $e')), + data: (report) => _buildBody(context, ref, report, projectsAsync, notifier), + ), + ); + } + + Widget _buildBody( + BuildContext context, + WidgetRef ref, + ReportData report, + AsyncValue projectsAsync, + ReportsNotifier notifier, + ) { + final chartLabels = _labelsFor(notifier.period, report.chartValues.length); + + return ListView( + children: [ + PeriodNavigator( + period: notifier.period, + periodStart: report.periodStart, + periodEnd: report.periodEnd, + onPrevious: notifier.goToPrevious, + onNext: notifier.goToNext, + ), + SummaryHeader( + totalDuration: report.totalDuration, + entryCount: report.durationByProject.length, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: SizedBox( + height: 200, + child: report.chartValues.isEmpty || + report.chartValues.every((v) => v == 0) + ? Center( + child: Text( + 'No data for this period', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ) + : DurationBarChart( + values: report.chartValues, + labels: chartLabels, + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), + child: Text( + 'By project', + style: Theme.of(context).textTheme.labelLarge, + ), + ), + projectsAsync.when( + data: (projects) => ProjectBreakdownList( + durationByProject: report.durationByProject, + projects: projects, + totalDuration: report.totalDuration, + ), + loading: () => const SizedBox.shrink(), + error: (e, _) => const SizedBox.shrink(), + ), + ], + ); + } + + List _labelsFor(ReportPeriod period, int count) { + switch (period) { + case ReportPeriod.week: + return _weekLabels; + case ReportPeriod.day: + return _dayLabels; + case ReportPeriod.month: + return List.generate(count, (i) => '${i + 1}'); + } + } +} + +class _PeriodTabs extends StatelessWidget { + const _PeriodTabs({required this.current, required this.onChanged}); + + final ReportPeriod current; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: ReportPeriod.values.map((p) { + final label = p.name[0].toUpperCase() + p.name.substring(1); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: ChoiceChip( + label: Text(label), + selected: current == p, + onSelected: (_) => onChanged(p), + ), + ); + }).toList(), + ); + } +} diff --git a/lib/features/reports/presentation/widgets/duration_bar_chart.dart b/lib/features/reports/presentation/widgets/duration_bar_chart.dart new file mode 100644 index 0000000..06e0076 --- /dev/null +++ b/lib/features/reports/presentation/widgets/duration_bar_chart.dart @@ -0,0 +1,78 @@ +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; + +class DurationBarChart extends StatelessWidget { + const DurationBarChart({ + super.key, + required this.values, + required this.labels, + }); + + /// Values in minutes (one per bar) + final List values; + + /// X-axis labels (e.g. ['Mon', 'Tue', …]) + final List labels; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final maxY = values.isEmpty + ? 60.0 + : (values.reduce((a, b) => a > b ? a : b) * 1.2).clamp(30.0, double.infinity); + + return BarChart( + BarChartData( + maxY: maxY, + barGroups: List.generate(values.length, (i) { + return BarChartGroupData( + x: i, + barRods: [ + BarChartRodData( + toY: values[i], + color: colorScheme.primary, + width: 16, + borderRadius: const BorderRadius.vertical(top: Radius.circular(4)), + ), + ], + ); + }), + titlesData: FlTitlesData( + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 36, + getTitlesWidget: (value, meta) => Text( + '${(value / 60).toStringAsFixed(0)}h', + style: Theme.of(context).textTheme.labelSmall, + ), + ), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + getTitlesWidget: (value, meta) { + final idx = value.toInt(); + if (idx < 0 || idx >= labels.length) return const SizedBox.shrink(); + return Text( + labels[idx], + style: Theme.of(context).textTheme.labelSmall, + ); + }, + ), + ), + rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + ), + gridData: FlGridData( + drawVerticalLine: false, + getDrawingHorizontalLine: (value) => FlLine( + color: colorScheme.outlineVariant, + strokeWidth: 0.5, + ), + ), + borderData: FlBorderData(show: false), + ), + ); + } +} diff --git a/lib/features/reports/presentation/widgets/period_navigator.dart b/lib/features/reports/presentation/widgets/period_navigator.dart new file mode 100644 index 0000000..4c85337 --- /dev/null +++ b/lib/features/reports/presentation/widgets/period_navigator.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import 'package:timetrack/features/reports/domain/report_data.dart'; + +class PeriodNavigator extends StatelessWidget { + const PeriodNavigator({ + super.key, + required this.period, + required this.periodStart, + required this.periodEnd, + required this.onPrevious, + required this.onNext, + }); + + final ReportPeriod period; + final DateTime periodStart; + final DateTime periodEnd; + final VoidCallback onPrevious; + final VoidCallback onNext; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + icon: const Icon(Icons.chevron_left), + onPressed: onPrevious, + ), + Text( + _label(), + style: Theme.of(context).textTheme.titleSmall, + ), + IconButton( + icon: const Icon(Icons.chevron_right), + onPressed: onNext, + ), + ], + ); + } + + String _label() { + switch (period) { + case ReportPeriod.day: + return DateFormat('EEE, d MMM y').format(periodStart); + case ReportPeriod.week: + return '${DateFormat('d MMM').format(periodStart)} – ' + '${DateFormat('d MMM y').format(periodEnd.subtract(const Duration(days: 1)))}'; + case ReportPeriod.month: + return DateFormat('MMMM y').format(periodStart); + } + } +} diff --git a/lib/features/reports/presentation/widgets/project_breakdown_list.dart b/lib/features/reports/presentation/widgets/project_breakdown_list.dart new file mode 100644 index 0000000..1860afc --- /dev/null +++ b/lib/features/reports/presentation/widgets/project_breakdown_list.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; + +import 'package:timetrack/features/projects/domain/project.dart'; + +class ProjectBreakdownList extends StatelessWidget { + const ProjectBreakdownList({ + super.key, + required this.durationByProject, + required this.projects, + required this.totalDuration, + }); + + final Map durationByProject; + final List projects; + final Duration totalDuration; + + @override + Widget build(BuildContext context) { + if (durationByProject.isEmpty) return const SizedBox.shrink(); + + final projectMap = {for (final p in projects) p.id: p}; + final sorted = durationByProject.entries.toList() + ..sort((a, b) => b.value.compareTo(a.value)); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: sorted.map((entry) { + final project = projectMap[entry.key]; + final pct = totalDuration.inSeconds > 0 + ? (entry.value.inSeconds / totalDuration.inSeconds * 100).round() + : 0; + final h = entry.value.inHours; + final m = entry.value.inMinutes % 60; + final label = h > 0 ? '${h}h ${m}m' : '${m}m'; + + return ListTile( + dense: true, + leading: CircleAvatar( + backgroundColor: Color(project?.colorValue ?? 0xFF888888), + radius: 10, + ), + title: Text(project?.name ?? 'Unknown'), + trailing: Text('$label ($pct%)'), + ); + }).toList(), + ); + } +} diff --git a/lib/features/reports/presentation/widgets/summary_header.dart b/lib/features/reports/presentation/widgets/summary_header.dart new file mode 100644 index 0000000..dd2e281 --- /dev/null +++ b/lib/features/reports/presentation/widgets/summary_header.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; + +class SummaryHeader extends StatelessWidget { + const SummaryHeader({ + super.key, + required this.totalDuration, + required this.entryCount, + }); + + final Duration totalDuration; + final int entryCount; + + @override + Widget build(BuildContext context) { + final h = totalDuration.inHours; + final m = totalDuration.inMinutes % 60; + final label = h > 0 ? '${h}h ${m}m' : '${m}m'; + + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Total', style: Theme.of(context).textTheme.labelMedium), + Text( + label, + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const Spacer(), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text('Entries', style: Theme.of(context).textTheme.labelMedium), + Text( + '$entryCount', + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/settings/data/export_service.dart b/lib/features/settings/data/export_service.dart new file mode 100644 index 0000000..c09f745 --- /dev/null +++ b/lib/features/settings/data/export_service.dart @@ -0,0 +1,173 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:csv/csv.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:pdf/widgets.dart' as pw; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:share_plus/share_plus.dart'; + +import 'package:timetrack/features/entries/data/drift_entries_repository.dart'; +import 'package:timetrack/features/entries/data/entries_repository.dart'; +import 'package:timetrack/features/entries/domain/time_entry.dart'; +import 'package:timetrack/features/projects/data/drift_projects_repository.dart'; +import 'package:timetrack/features/projects/data/projects_repository.dart'; +import 'package:timetrack/features/projects/domain/project.dart'; + +part 'export_service.g.dart'; + +enum ExportFormat { csv, pdf, json } + +@riverpod +ExportService exportService(ExportServiceRef ref) { + return ExportService( + entriesRepo: ref.watch(timeEntriesRepositoryProvider), + projectsRepo: ref.watch(projectsRepositoryProvider), + ); +} + +class ExportService { + ExportService({ + required this.entriesRepo, + required this.projectsRepo, + }); + + final TimeEntriesRepository entriesRepo; + final ProjectsRepository projectsRepo; + + Future export({ + required ExportFormat format, + required DateTime from, + required DateTime to, + int? projectId, + }) async { + final entries = await entriesRepo.watchByDateRange(from, to).first; + final allProjects = await projectsRepo.watchAll().first; + final projectMap = {for (final p in allProjects) p.id: p}; + + final filtered = projectId != null + ? entries.where((e) => e.projectId == projectId).toList() + : entries; + + switch (format) { + case ExportFormat.csv: + await _exportCsv(filtered, projectMap); + break; + case ExportFormat.pdf: + await _exportPdf(filtered, projectMap, from, to); + break; + case ExportFormat.json: + await _exportJson(filtered, allProjects); + break; + } + } + + Future _exportCsv( + List entries, + Map projectMap, + ) async { + final rows = >[ + ['id', 'project', 'start_time', 'end_time', 'duration_seconds', 'note', 'tags'], + ...entries.map((e) => [ + e.id, + projectMap[e.projectId]?.name ?? '', + e.startTime.toIso8601String(), + e.endTime?.toIso8601String() ?? '', + e.durationSeconds ?? '', + e.note ?? '', + e.tags.join('|'), + ]), + ]; + + final csvStr = const ListToCsvConverter().convert(rows); + // UTF-8 BOM for Excel compatibility + final bom = '\uFEFF'; + final content = bom + csvStr; + + final file = await _tempFile('timetrack_export', 'csv'); + await file.writeAsString(content, encoding: utf8); + await Share.shareXFiles([XFile(file.path)], + subject: 'Timetrack Export'); + } + + Future _exportPdf( + List entries, + Map projectMap, + DateTime from, + DateTime to, + ) async { + final doc = pw.Document(); + + doc.addPage(pw.Page( + build: (ctx) => pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text('Timetrack Export', + style: pw.TextStyle(fontSize: 20, fontWeight: pw.FontWeight.bold)), + pw.SizedBox(height: 4), + pw.Text( + '${from.toIso8601String().substring(0, 10)} – ${to.toIso8601String().substring(0, 10)}'), + pw.SizedBox(height: 16), + pw.TableHelper.fromTextArray( + headers: ['Project', 'Start', 'End', 'Duration', 'Note'], + data: entries.map((e) { + final dur = Duration(seconds: e.durationSeconds ?? 0); + return [ + projectMap[e.projectId]?.name ?? '—', + e.startTime.toIso8601String().substring(0, 16), + e.endTime?.toIso8601String().substring(0, 16) ?? '—', + '${dur.inHours}h ${dur.inMinutes % 60}m', + e.note ?? '', + ]; + }).toList(), + ), + ], + ), + )); + + final file = await _tempFile('timetrack_report', 'pdf'); + await file.writeAsBytes(await doc.save()); + await Share.shareXFiles([XFile(file.path)], + subject: 'Timetrack Report'); + } + + Future _exportJson( + List entries, + List projects, + ) async { + final data = { + 'exportedAt': DateTime.now().toIso8601String(), + 'version': 1, + 'projects': projects + .map((p) => { + 'id': p.id, + 'name': p.name, + 'color': p.colorValue, + 'description': p.description, + }) + .toList(), + 'entries': entries + .map((e) => { + 'id': e.id, + 'projectId': e.projectId, + 'startTime': e.startTime.toIso8601String(), + 'endTime': e.endTime?.toIso8601String(), + 'durationSeconds': e.durationSeconds, + 'note': e.note, + 'tags': e.tags, + }) + .toList(), + }; + + final file = await _tempFile('timetrack_backup', 'json'); + await file.writeAsString(const JsonEncoder.withIndent(' ').convert(data)); + await Share.shareXFiles([XFile(file.path)], + subject: 'Timetrack Backup'); + } + + Future _tempFile(String name, String ext) async { + final dir = await getTemporaryDirectory(); + final date = DateTime.now().toIso8601String().substring(0, 10); + return File('${dir.path}/${name}_$date.$ext'); + } +} diff --git a/lib/features/settings/data/export_service.g.dart b/lib/features/settings/data/export_service.g.dart new file mode 100644 index 0000000..c59582a --- /dev/null +++ b/lib/features/settings/data/export_service.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'export_service.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$exportServiceHash() => r'edbb14895340d6bc00972bb79537b7c5dd300987'; + +/// See also [exportService]. +@ProviderFor(exportService) +final exportServiceProvider = AutoDisposeProvider.internal( + exportService, + name: r'exportServiceProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$exportServiceHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef ExportServiceRef = AutoDisposeProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/settings/domain/settings_provider.dart b/lib/features/settings/domain/settings_provider.dart new file mode 100644 index 0000000..f0db47b --- /dev/null +++ b/lib/features/settings/domain/settings_provider.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +part 'settings_provider.g.dart'; + +@Riverpod(keepAlive: true) +Future sharedPreferences(SharedPreferencesRef ref) => + SharedPreferences.getInstance(); + +@riverpod +class ThemeModeNotifier extends _$ThemeModeNotifier { + static const _key = 'theme_mode'; + + @override + ThemeMode build() { + final prefs = ref.watch(sharedPreferencesProvider).valueOrNull; + final stored = prefs?.getString(_key); + return switch (stored) { + 'light' => ThemeMode.light, + 'dark' => ThemeMode.dark, + _ => ThemeMode.system, + }; + } + + Future setThemeMode(ThemeMode mode) async { + final prefs = await ref.read(sharedPreferencesProvider.future); + await prefs.setString(_key, mode.name); + state = mode; + } +} + +@riverpod +class LocaleNotifier extends _$LocaleNotifier { + static const _key = 'locale'; + + @override + Locale? build() { + final prefs = ref.watch(sharedPreferencesProvider).valueOrNull; + final stored = prefs?.getString(_key); + if (stored == null) return null; // use device locale + return Locale(stored); + } + + Future setLocale(Locale? locale) async { + final prefs = await ref.read(sharedPreferencesProvider.future); + if (locale == null) { + await prefs.remove(_key); + } else { + await prefs.setString(_key, locale.languageCode); + } + state = locale; + } +} + +@riverpod +class QuickAccessCountNotifier extends _$QuickAccessCountNotifier { + static const _key = 'quick_access_count'; + static const int minCount = 4; + static const int maxCount = 10; + static const int defaultCount = 6; + + @override + int build() { + final prefs = ref.watch(sharedPreferencesProvider).valueOrNull; + final stored = prefs?.getInt(_key); + if (stored == null) return defaultCount; + return stored.clamp(minCount, maxCount); + } + + Future setCount(int count) async { + final clamped = count.clamp(minCount, maxCount); + final prefs = await ref.read(sharedPreferencesProvider.future); + await prefs.setInt(_key, clamped); + state = clamped; + } +} + diff --git a/lib/features/settings/domain/settings_provider.g.dart b/lib/features/settings/domain/settings_provider.g.dart new file mode 100644 index 0000000..887be73 --- /dev/null +++ b/lib/features/settings/domain/settings_provider.g.dart @@ -0,0 +1,76 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'settings_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$sharedPreferencesHash() => r'25eceea0052302f519f44a896409ba30ede45562'; + +/// See also [sharedPreferences]. +@ProviderFor(sharedPreferences) +final sharedPreferencesProvider = FutureProvider.internal( + sharedPreferences, + name: r'sharedPreferencesProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$sharedPreferencesHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef SharedPreferencesRef = FutureProviderRef; +String _$themeModeNotifierHash() => r'113f8cb0ae4e3761627013b103f88513d1945af4'; + +/// See also [ThemeModeNotifier]. +@ProviderFor(ThemeModeNotifier) +final themeModeNotifierProvider = + AutoDisposeNotifierProvider.internal( + ThemeModeNotifier.new, + name: r'themeModeNotifierProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$themeModeNotifierHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$ThemeModeNotifier = AutoDisposeNotifier; +String _$localeNotifierHash() => r'3d79fb71e7068f06a5b84e68e77e69644e3d5460'; + +/// See also [LocaleNotifier]. +@ProviderFor(LocaleNotifier) +final localeNotifierProvider = + AutoDisposeNotifierProvider.internal( + LocaleNotifier.new, + name: r'localeNotifierProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$localeNotifierHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$LocaleNotifier = AutoDisposeNotifier; +String _$quickAccessCountNotifierHash() => + r'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2'; + +/// See also [QuickAccessCountNotifier]. +@ProviderFor(QuickAccessCountNotifier) +final quickAccessCountNotifierProvider = + AutoDisposeNotifierProvider.internal( + QuickAccessCountNotifier.new, + name: r'quickAccessCountNotifierProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$quickAccessCountNotifierHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$QuickAccessCountNotifier = AutoDisposeNotifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/settings/presentation/settings_screen.dart b/lib/features/settings/presentation/settings_screen.dart new file mode 100644 index 0000000..451ac19 --- /dev/null +++ b/lib/features/settings/presentation/settings_screen.dart @@ -0,0 +1,160 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:timetrack/features/settings/domain/settings_provider.dart'; +import 'package:timetrack/features/settings/presentation/widgets/export_sheet.dart'; + +class SettingsScreen extends ConsumerWidget { + const SettingsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final themeMode = ref.watch(themeModeNotifierProvider); + final locale = ref.watch(localeNotifierProvider); + final quickAccessCount = ref.watch(quickAccessCountNotifierProvider); + + return Scaffold( + appBar: AppBar(title: const Text('Settings')), + body: ListView( + children: [ + // Appearance + _SectionHeader(label: 'Appearance'), + ListTile( + leading: const Icon(Icons.brightness_6_outlined), + title: const Text('Theme'), + trailing: DropdownButton( + value: themeMode, + underline: const SizedBox.shrink(), + items: const [ + DropdownMenuItem(value: ThemeMode.system, child: Text('System')), + DropdownMenuItem(value: ThemeMode.light, child: Text('Light')), + DropdownMenuItem(value: ThemeMode.dark, child: Text('Dark')), + ], + onChanged: (mode) { + if (mode != null) { + ref.read(themeModeNotifierProvider.notifier).setThemeMode(mode); + } + }, + ), + ), + ListTile( + leading: const Icon(Icons.language_outlined), + title: const Text('Language'), + trailing: DropdownButton( + value: locale, + underline: const SizedBox.shrink(), + items: const [ + DropdownMenuItem(value: null, child: Text('System')), + DropdownMenuItem(value: Locale('en'), child: Text('English')), + DropdownMenuItem(value: Locale('de'), child: Text('Deutsch')), + ], + onChanged: (l) { + ref.read(localeNotifierProvider.notifier).setLocale(l); + }, + ), + ), + + const Divider(), + + // Timer + _SectionHeader(label: 'Timer'), + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: Row( + children: [ + const Icon(Icons.bolt_outlined, size: 20), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Quick access projects', + style: Theme.of(context).textTheme.bodyLarge, + ), + Text( + 'Number of frequently used projects shown above the project picker (${QuickAccessCountNotifier.minCount}–${QuickAccessCountNotifier.maxCount})', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + Text( + '$quickAccessCount', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + Slider( + value: quickAccessCount.toDouble(), + min: QuickAccessCountNotifier.minCount.toDouble(), + max: QuickAccessCountNotifier.maxCount.toDouble(), + divisions: QuickAccessCountNotifier.maxCount - QuickAccessCountNotifier.minCount, + label: '$quickAccessCount', + onChanged: (value) { + ref + .read(quickAccessCountNotifierProvider.notifier) + .setCount(value.round()); + }, + ), + + const Divider(), + + // Data + _SectionHeader(label: 'Data'), + ListTile( + leading: const Icon(Icons.upload_outlined), + title: const Text('Export data'), + trailing: const Icon(Icons.arrow_forward_ios, size: 16), + onTap: () => ExportSheet.show(context), + ), + + const Divider(), + + // About + _SectionHeader(label: 'About'), + const ListTile( + leading: Icon(Icons.info_outline), + title: Text('Version'), + trailing: Text('0.1.0'), + ), + ListTile( + leading: const Icon(Icons.article_outlined), + title: const Text('Licences'), + onTap: () => showLicensePage( + context: context, + applicationName: 'Timetrack', + applicationVersion: '0.1.0', + ), + ), + ], + ), + ); + } +} + +class _SectionHeader extends StatelessWidget { + const _SectionHeader({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), + child: Text( + label, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: Theme.of(context).colorScheme.primary, + ), + ), + ); + } +} + diff --git a/lib/features/settings/presentation/widgets/export_sheet.dart b/lib/features/settings/presentation/widgets/export_sheet.dart new file mode 100644 index 0000000..5ceb8c1 --- /dev/null +++ b/lib/features/settings/presentation/widgets/export_sheet.dart @@ -0,0 +1,167 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:timetrack/features/projects/domain/projects_provider.dart'; +import 'package:timetrack/features/settings/data/export_service.dart'; + +class ExportSheet extends ConsumerStatefulWidget { + const ExportSheet({super.key}); + + static Future show(BuildContext context) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (_) => const ExportSheet(), + ); + } + + @override + ConsumerState createState() => _ExportSheetState(); +} + +class _ExportSheetState extends ConsumerState { + ExportFormat _format = ExportFormat.csv; + _Range _range = _Range.thisWeek; + int? _projectId; + bool _exporting = false; + + @override + Widget build(BuildContext context) { + final projectsAsync = ref.watch(allProjectsProvider); + + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom + 16, + top: 16, + left: 16, + right: 16, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Export data', style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 16), + + // Format + Text('Format', style: Theme.of(context).textTheme.labelLarge), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: ExportFormat.values.map((f) { + final label = f.name.toUpperCase(); + return ChoiceChip( + label: Text(label), + selected: _format == f, + onSelected: (_) => setState(() => _format = f), + ); + }).toList(), + ), + const SizedBox(height: 12), + + // Date range + Text('Date range', style: Theme.of(context).textTheme.labelLarge), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: _Range.values.map((r) { + return ChoiceChip( + label: Text(r.label), + selected: _range == r, + onSelected: (_) => setState(() => _range = r), + ); + }).toList(), + ), + const SizedBox(height: 12), + + // Project filter + projectsAsync.when( + data: (projects) => DropdownButtonFormField( + initialValue: _projectId, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'Project (optional)', + ), + items: [ + const DropdownMenuItem(value: null, child: Text('All projects')), + ...projects.map((p) => DropdownMenuItem( + value: p.id, + child: Text(p.name), + )), + ], + onChanged: (id) => setState(() => _projectId = id), + ), + loading: () => const LinearProgressIndicator(), + error: (e, _) => const SizedBox.shrink(), + ), + + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: _exporting ? null : _doExport, + icon: _exporting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.share), + label: const Text('Share'), + ), + ), + ], + ), + ); + } + + Future _doExport() async { + setState(() => _exporting = true); + try { + final (from, to) = _range.bounds; + await ref.read(exportServiceProvider).export( + format: _format, + from: from, + to: to, + projectId: _projectId, + ); + if (mounted) Navigator.pop(context); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Export failed: $e')), + ); + } + } finally { + if (mounted) setState(() => _exporting = false); + } + } +} + +enum _Range { + today('Today'), + thisWeek('This week'), + thisMonth('This month'); + + const _Range(this.label); + final String label; + + (DateTime, DateTime) get bounds { + final now = DateTime.now(); + switch (this) { + case _Range.today: + final start = DateTime(now.year, now.month, now.day); + return (start, start.add(const Duration(days: 1))); + case _Range.thisWeek: + final monday = now.subtract(Duration(days: now.weekday - 1)); + final start = DateTime(monday.year, monday.month, monday.day); + return (start, start.add(const Duration(days: 7))); + case _Range.thisMonth: + final start = DateTime(now.year, now.month); + return (start, DateTime(now.year, now.month + 1)); + } + } +} diff --git a/lib/features/timer/data/timer_repository.dart b/lib/features/timer/data/timer_repository.dart new file mode 100644 index 0000000..713e0f8 --- /dev/null +++ b/lib/features/timer/data/timer_repository.dart @@ -0,0 +1,4 @@ +// ignore_for_file: one_member_abstracts +abstract class TimerRepository { + // TODO: implement timer persistence +} diff --git a/lib/features/timer/domain/frequent_projects_provider.dart b/lib/features/timer/domain/frequent_projects_provider.dart new file mode 100644 index 0000000..a8e7736 --- /dev/null +++ b/lib/features/timer/domain/frequent_projects_provider.dart @@ -0,0 +1,98 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:timetrack/features/entries/domain/entries_provider.dart'; +import 'package:timetrack/features/projects/domain/project.dart'; +import 'package:timetrack/features/projects/domain/projects_provider.dart'; +import 'package:timetrack/features/settings/domain/settings_provider.dart'; + +part 'frequent_projects_provider.g.dart'; + +/// Returns the top-N most frequently + recently used active projects. +/// +/// Score = 0.30 * normalised_count + 0.70 * normalised_recency +/// - count : number of time entries in the last 30 days for that project +/// - recency : milliseconds since last entry, inverted and normalised +/// +/// Scored projects are always shown first (highest score → left/top). +/// Remaining slots up to N are filled with the oldest active projects +/// (by createdAt) that are not already in the scored set. +/// Only when there are >= N scored projects are unscored projects omitted. +/// +/// N is controlled by [quickAccessCountNotifierProvider] (default 6). +@riverpod +List frequentProjects(FrequentProjectsRef ref) { + final projectsAsync = ref.watch(activeProjectsProvider); + final entriesAsync = ref.watch(allEntriesProvider); + final count = ref.watch(quickAccessCountNotifierProvider); + + final projects = projectsAsync.valueOrNull ?? []; + final entries = entriesAsync.valueOrNull ?? []; + + if (projects.isEmpty) return []; + + final cutoff = DateTime.now().subtract(const Duration(days: 30)); + + // Aggregate per project: entry count (30d) + most recent start time (all time) + final Map countMap = {}; + final Map lastUsedMap = {}; + + for (final e in entries) { + final projectId = e.projectId; + if (e.startTime.isAfter(cutoff)) { + countMap[projectId] = (countMap[projectId] ?? 0) + 1; + } + final current = lastUsedMap[projectId]; + if (current == null || e.startTime.isAfter(current)) { + lastUsedMap[projectId] = e.startTime; + } + } + + // Only keep active projects that have at least one entry + final scored = projects + .where((p) => countMap.containsKey(p.id) || lastUsedMap.containsKey(p.id)) + .toList(); + + // Fallback: no entries at all → show the first N projects by creation date + if (scored.isEmpty) { + final fallback = [...projects] + ..sort((a, b) => a.createdAt.compareTo(b.createdAt)); + return fallback.take(count).toList(); + } + + // Sort scored projects by descending score + final maxCount = scored + .map((p) => countMap[p.id] ?? 0) + .reduce((a, b) => a > b ? a : b); + + final now = DateTime.now(); + final maxAge = scored.map((p) { + final last = lastUsedMap[p.id]; + return last != null ? now.difference(last).inMilliseconds : 0.0; + }).fold(0.0, (prev, age) => age > prev ? age.toDouble() : prev); + + // Pre-compute scores into a map keyed by project id to avoid + // using indexOf() inside the sort comparator (which causes RangeError + // when the list is mutated mid-sort). + final scoreMap = {}; + for (final p in scored) { + final normCount = maxCount > 0 ? (countMap[p.id] ?? 0) / maxCount : 0.0; + final last = lastUsedMap[p.id]; + final age = last != null ? now.difference(last).inMilliseconds.toDouble() : 0.0; + final normRecency = maxAge > 0 ? 1.0 - (age / maxAge) : 0.0; + scoreMap[p.id] = 0.30 * normCount + 0.70 * normRecency; + } + + scored.sort((a, b) => scoreMap[b.id]!.compareTo(scoreMap[a.id]!)); + + // If we already have enough scored projects, return top-N directly + if (scored.length >= count) { + return scored.take(count).toList(); + } + + // Fill remaining slots with the oldest projects not already in the scored set + final scoredIds = scored.map((p) => p.id).toSet(); + final filler = [...projects.where((p) => !scoredIds.contains(p.id))] + ..sort((a, b) => a.createdAt.compareTo(b.createdAt)); + + return [...scored, ...filler].take(count).toList(); +} diff --git a/lib/features/timer/domain/frequent_projects_provider.g.dart b/lib/features/timer/domain/frequent_projects_provider.g.dart new file mode 100644 index 0000000..ec4f42d --- /dev/null +++ b/lib/features/timer/domain/frequent_projects_provider.g.dart @@ -0,0 +1,28 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'frequent_projects_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$frequentProjectsHash() => r'b1c2d3e4f5a6b1c2d3e4f5a6b1c2d3e4f5a6b1c2'; + +/// See also [frequentProjects]. +@ProviderFor(frequentProjects) +final frequentProjectsProvider = + AutoDisposeProvider>.internal( + frequentProjects, + name: r'frequentProjectsProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$frequentProjectsHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef FrequentProjectsRef = AutoDisposeProviderRef>; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/timer/domain/timer_notifier.dart b/lib/features/timer/domain/timer_notifier.dart new file mode 100644 index 0000000..eef0926 --- /dev/null +++ b/lib/features/timer/domain/timer_notifier.dart @@ -0,0 +1,99 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:timetrack/core/database/app_database.dart' as db; +import 'package:timetrack/features/entries/data/drift_entries_repository.dart'; +import 'package:timetrack/features/entries/domain/time_entry.dart' as domain; +import 'package:timetrack/features/projects/data/project_mapper.dart'; +import 'package:timetrack/features/projects/domain/project.dart'; +import 'package:timetrack/features/timer/domain/timer_state.dart'; + +part 'timer_notifier.g.dart'; + +@riverpod +class TimerNotifier extends _$TimerNotifier { + @override + TimerState build() { + _restoreFromDb(); + return const TimerState.idle(); + } + + Future _restoreFromDb() async { + final repo = ref.read(timeEntriesRepositoryProvider); + final active = await repo.getActiveEntry(); + if (active == null) return; + + final projectRow = await ref + .read(db.appDatabaseProvider) + .projectsDao + .getById(active.projectId); + if (projectRow == null) return; + + state = TimerState.running( + entryId: active.id, + startTime: active.startTime, + project: projectRow.toDomain(), + note: active.note, + ); + } + + Future start(Project project, {String? note}) async { + if (state is TimerRunning) await stop(); + + final repo = ref.read(timeEntriesRepositoryProvider); + final now = DateTime.now(); + final id = await repo.create(domain.TimeEntry( + id: 0, + projectId: project.id, + startTime: now, + tags: const [], + createdAt: now, + note: note, + )); + + state = TimerState.running( + entryId: id, + startTime: now, + project: project, + note: note, + ); + } + + Future stop() async { + final running = state; + if (running is! TimerRunning) return; + + final now = DateTime.now(); + final repo = ref.read(timeEntriesRepositoryProvider); + final current = await repo.getActiveEntry(); + if (current != null) { + await repo.update(current.copyWith( + endTime: now, + durationSeconds: now.difference(running.startTime).inSeconds, + )); + } + state = const TimerState.idle(); + } + + Future discard() async { + final running = state; + if (running is! TimerRunning) return; + + await ref.read(timeEntriesRepositoryProvider).delete(running.entryId); + state = const TimerState.idle(); + } + + void updateNote(String note) { + final running = state; + if (running is! TimerRunning) return; + state = running.copyWith(note: note); + _persistNote(running.entryId, note); + } + + Future _persistNote(int entryId, String note) async { + final repo = ref.read(timeEntriesRepositoryProvider); + final entry = await repo.getActiveEntry(); + if (entry != null) { + await repo.update(entry.copyWith(note: note)); + } + } +} diff --git a/lib/features/timer/domain/timer_notifier.g.dart b/lib/features/timer/domain/timer_notifier.g.dart new file mode 100644 index 0000000..56f4fbd --- /dev/null +++ b/lib/features/timer/domain/timer_notifier.g.dart @@ -0,0 +1,26 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'timer_notifier.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$timerNotifierHash() => r'1db4fc658b93764923b60875c45a9580460533a1'; + +/// See also [TimerNotifier]. +@ProviderFor(TimerNotifier) +final timerNotifierProvider = + AutoDisposeNotifierProvider.internal( + TimerNotifier.new, + name: r'timerNotifierProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$timerNotifierHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$TimerNotifier = AutoDisposeNotifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/timer/domain/timer_state.dart b/lib/features/timer/domain/timer_state.dart new file mode 100644 index 0000000..f92209f --- /dev/null +++ b/lib/features/timer/domain/timer_state.dart @@ -0,0 +1,17 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import 'package:timetrack/features/projects/domain/project.dart'; + +part 'timer_state.freezed.dart'; + +@freezed +class TimerState with _$TimerState { + const factory TimerState.idle() = TimerIdle; + + const factory TimerState.running({ + required int entryId, + required DateTime startTime, + required Project project, + String? note, + }) = TimerRunning; +} diff --git a/lib/features/timer/domain/timer_state.freezed.dart b/lib/features/timer/domain/timer_state.freezed.dart new file mode 100644 index 0000000..ee5f6ec --- /dev/null +++ b/lib/features/timer/domain/timer_state.freezed.dart @@ -0,0 +1,430 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'timer_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models', +); + +/// @nodoc +mixin _$TimerState { + @optionalTypeArgs + TResult when({ + required TResult Function() idle, + required TResult Function( + int entryId, + DateTime startTime, + Project project, + String? note, + ) + running, + }) => throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? idle, + TResult? Function( + int entryId, + DateTime startTime, + Project project, + String? note, + )? + running, + }) => throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? idle, + TResult Function( + int entryId, + DateTime startTime, + Project project, + String? note, + )? + running, + required TResult orElse(), + }) => throw _privateConstructorUsedError; + @optionalTypeArgs + TResult map({ + required TResult Function(TimerIdle value) idle, + required TResult Function(TimerRunning value) running, + }) => throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(TimerIdle value)? idle, + TResult? Function(TimerRunning value)? running, + }) => throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeMap({ + TResult Function(TimerIdle value)? idle, + TResult Function(TimerRunning value)? running, + required TResult orElse(), + }) => throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $TimerStateCopyWith<$Res> { + factory $TimerStateCopyWith( + TimerState value, + $Res Function(TimerState) then, + ) = _$TimerStateCopyWithImpl<$Res, TimerState>; +} + +/// @nodoc +class _$TimerStateCopyWithImpl<$Res, $Val extends TimerState> + implements $TimerStateCopyWith<$Res> { + _$TimerStateCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of TimerState + /// with the given fields replaced by the non-null parameter values. +} + +/// @nodoc +abstract class _$$TimerIdleImplCopyWith<$Res> { + factory _$$TimerIdleImplCopyWith( + _$TimerIdleImpl value, + $Res Function(_$TimerIdleImpl) then, + ) = __$$TimerIdleImplCopyWithImpl<$Res>; +} + +/// @nodoc +class __$$TimerIdleImplCopyWithImpl<$Res> + extends _$TimerStateCopyWithImpl<$Res, _$TimerIdleImpl> + implements _$$TimerIdleImplCopyWith<$Res> { + __$$TimerIdleImplCopyWithImpl( + _$TimerIdleImpl _value, + $Res Function(_$TimerIdleImpl) _then, + ) : super(_value, _then); + + /// Create a copy of TimerState + /// with the given fields replaced by the non-null parameter values. +} + +/// @nodoc + +class _$TimerIdleImpl implements TimerIdle { + const _$TimerIdleImpl(); + + @override + String toString() { + return 'TimerState.idle()'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && other is _$TimerIdleImpl); + } + + @override + int get hashCode => runtimeType.hashCode; + + @override + @optionalTypeArgs + TResult when({ + required TResult Function() idle, + required TResult Function( + int entryId, + DateTime startTime, + Project project, + String? note, + ) + running, + }) { + return idle(); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? idle, + TResult? Function( + int entryId, + DateTime startTime, + Project project, + String? note, + )? + running, + }) { + return idle?.call(); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? idle, + TResult Function( + int entryId, + DateTime startTime, + Project project, + String? note, + )? + running, + required TResult orElse(), + }) { + if (idle != null) { + return idle(); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(TimerIdle value) idle, + required TResult Function(TimerRunning value) running, + }) { + return idle(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(TimerIdle value)? idle, + TResult? Function(TimerRunning value)? running, + }) { + return idle?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(TimerIdle value)? idle, + TResult Function(TimerRunning value)? running, + required TResult orElse(), + }) { + if (idle != null) { + return idle(this); + } + return orElse(); + } +} + +abstract class TimerIdle implements TimerState { + const factory TimerIdle() = _$TimerIdleImpl; +} + +/// @nodoc +abstract class _$$TimerRunningImplCopyWith<$Res> { + factory _$$TimerRunningImplCopyWith( + _$TimerRunningImpl value, + $Res Function(_$TimerRunningImpl) then, + ) = __$$TimerRunningImplCopyWithImpl<$Res>; + @useResult + $Res call({int entryId, DateTime startTime, Project project, String? note}); + + $ProjectCopyWith<$Res> get project; +} + +/// @nodoc +class __$$TimerRunningImplCopyWithImpl<$Res> + extends _$TimerStateCopyWithImpl<$Res, _$TimerRunningImpl> + implements _$$TimerRunningImplCopyWith<$Res> { + __$$TimerRunningImplCopyWithImpl( + _$TimerRunningImpl _value, + $Res Function(_$TimerRunningImpl) _then, + ) : super(_value, _then); + + /// Create a copy of TimerState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? entryId = null, + Object? startTime = null, + Object? project = null, + Object? note = freezed, + }) { + return _then( + _$TimerRunningImpl( + entryId: null == entryId + ? _value.entryId + : entryId // ignore: cast_nullable_to_non_nullable + as int, + startTime: null == startTime + ? _value.startTime + : startTime // ignore: cast_nullable_to_non_nullable + as DateTime, + project: null == project + ? _value.project + : project // ignore: cast_nullable_to_non_nullable + as Project, + note: freezed == note + ? _value.note + : note // ignore: cast_nullable_to_non_nullable + as String?, + ), + ); + } + + /// Create a copy of TimerState + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $ProjectCopyWith<$Res> get project { + return $ProjectCopyWith<$Res>(_value.project, (value) { + return _then(_value.copyWith(project: value)); + }); + } +} + +/// @nodoc + +class _$TimerRunningImpl implements TimerRunning { + const _$TimerRunningImpl({ + required this.entryId, + required this.startTime, + required this.project, + this.note, + }); + + @override + final int entryId; + @override + final DateTime startTime; + @override + final Project project; + @override + final String? note; + + @override + String toString() { + return 'TimerState.running(entryId: $entryId, startTime: $startTime, project: $project, note: $note)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$TimerRunningImpl && + (identical(other.entryId, entryId) || other.entryId == entryId) && + (identical(other.startTime, startTime) || + other.startTime == startTime) && + (identical(other.project, project) || other.project == project) && + (identical(other.note, note) || other.note == note)); + } + + @override + int get hashCode => + Object.hash(runtimeType, entryId, startTime, project, note); + + /// Create a copy of TimerState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$TimerRunningImplCopyWith<_$TimerRunningImpl> get copyWith => + __$$TimerRunningImplCopyWithImpl<_$TimerRunningImpl>(this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function() idle, + required TResult Function( + int entryId, + DateTime startTime, + Project project, + String? note, + ) + running, + }) { + return running(entryId, startTime, project, note); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? idle, + TResult? Function( + int entryId, + DateTime startTime, + Project project, + String? note, + )? + running, + }) { + return running?.call(entryId, startTime, project, note); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? idle, + TResult Function( + int entryId, + DateTime startTime, + Project project, + String? note, + )? + running, + required TResult orElse(), + }) { + if (running != null) { + return running(entryId, startTime, project, note); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(TimerIdle value) idle, + required TResult Function(TimerRunning value) running, + }) { + return running(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(TimerIdle value)? idle, + TResult? Function(TimerRunning value)? running, + }) { + return running?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(TimerIdle value)? idle, + TResult Function(TimerRunning value)? running, + required TResult orElse(), + }) { + if (running != null) { + return running(this); + } + return orElse(); + } +} + +abstract class TimerRunning implements TimerState { + const factory TimerRunning({ + required final int entryId, + required final DateTime startTime, + required final Project project, + final String? note, + }) = _$TimerRunningImpl; + + int get entryId; + DateTime get startTime; + Project get project; + String? get note; + + /// Create a copy of TimerState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + _$$TimerRunningImplCopyWith<_$TimerRunningImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/features/timer/presentation/timer_screen.dart b/lib/features/timer/presentation/timer_screen.dart new file mode 100644 index 0000000..1b86074 --- /dev/null +++ b/lib/features/timer/presentation/timer_screen.dart @@ -0,0 +1,178 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:timetrack/features/entries/domain/entries_provider.dart'; +import 'package:timetrack/features/projects/domain/project.dart'; +import 'package:timetrack/features/timer/domain/timer_notifier.dart'; +import 'package:timetrack/features/timer/domain/timer_state.dart'; +import 'package:timetrack/features/timer/presentation/widgets/quick_access_grid.dart'; +import 'package:timetrack/features/timer/presentation/widgets/project_picker_chip.dart'; +import 'package:timetrack/features/timer/presentation/widgets/timer_controls.dart'; +import 'package:timetrack/features/timer/presentation/widgets/timer_display.dart'; +import 'package:timetrack/features/timer/presentation/widgets/today_summary_card.dart'; + +class TimerScreen extends ConsumerStatefulWidget { + const TimerScreen({super.key}); + + @override + ConsumerState createState() => _TimerScreenState(); +} + +class _TimerScreenState extends ConsumerState { + Project? _selectedProject; + final _noteController = TextEditingController(); + Timer? _ticker; + + @override + void dispose() { + _ticker?.cancel(); + _noteController.dispose(); + super.dispose(); + } + + void _startTicker() { + _ticker?.cancel(); + _ticker = Timer.periodic(const Duration(seconds: 1), (_) { + if (mounted) setState(() {}); + }); + } + + void _stopTicker() { + _ticker?.cancel(); + _ticker = null; + } + + @override + Widget build(BuildContext context) { + final timerState = ref.watch(timerNotifierProvider); + final isRunning = timerState is TimerRunning; + + // Start or stop the local ticker based on running state + if (isRunning && _ticker == null) { + WidgetsBinding.instance.addPostFrameCallback((_) => _startTicker()); + } else if (!isRunning && _ticker != null) { + _stopTicker(); + } + + // Sync selected project from running state + if (isRunning && _selectedProject?.id != timerState.project.id) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) setState(() => _selectedProject = timerState.project); + }); + } + + final elapsed = isRunning + ? DateTime.now().difference(timerState.startTime) + : Duration.zero; + + return Scaffold( + appBar: AppBar(title: const Text('Timer')), + body: SingleChildScrollView( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SizedBox(height: 24), + const QuickAccessGrid(), + const SizedBox(height: 16), + ProjectPickerChip( + project: isRunning ? timerState.project : _selectedProject, + enabled: !isRunning, + onChanged: (p) => setState(() => _selectedProject = p), + ), + const SizedBox(height: 40), + TimerDisplay(elapsed: elapsed), + const SizedBox(height: 40), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: TextField( + controller: _noteController, + decoration: const InputDecoration( + hintText: 'Add a note…', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.notes_outlined), + ), + maxLines: 1, + onChanged: isRunning + ? (val) => + ref.read(timerNotifierProvider.notifier).updateNote(val) + : null, + ), + ), + const SizedBox(height: 40), + TimerControls( + state: timerState, + selectedProject: _selectedProject, + ), + const SizedBox(height: 32), + const TodaySummaryCard(), + const SizedBox(height: 16), + const _RecentEntriesList(), + ], + ), + ), + ); + } +} + +class _RecentEntriesList extends ConsumerWidget { + const _RecentEntriesList(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final now = DateTime.now(); + final todayStart = DateTime(now.year, now.month, now.day); + final todayEnd = todayStart.add(const Duration(days: 1)); + + final entriesAsync = ref.watch( + entriesByDateRangeProvider(rangeFrom: todayStart, rangeTo: todayEnd), + ); + + return entriesAsync.when( + data: (entries) { + final completed = + entries.where((e) => e.endTime != null).take(5).toList(); + if (completed.isEmpty) return const SizedBox.shrink(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text( + 'Recent today', + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ...completed.map( + (entry) => ListTile( + dense: true, + leading: const Icon(Icons.access_time, size: 18), + title: Text(entry.note ?? '—'), + trailing: Text( + _fmt(Duration(seconds: entry.durationSeconds ?? 0)), + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ), + ], + ); + }, + loading: () => const SizedBox.shrink(), + error: (e, st) => const SizedBox.shrink(), + ); + } + + String _fmt(Duration d) { + final h = d.inHours; + final m = d.inMinutes % 60; + final s = d.inSeconds % 60; + if (h > 0) return '${h}h ${m}m'; + if (m > 0) return '${m}m ${s}s'; + return '${s}s'; + } +} diff --git a/lib/features/timer/presentation/widgets/project_picker_chip.dart b/lib/features/timer/presentation/widgets/project_picker_chip.dart new file mode 100644 index 0000000..c143638 --- /dev/null +++ b/lib/features/timer/presentation/widgets/project_picker_chip.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; + +import 'package:timetrack/features/projects/domain/project.dart'; +import 'package:timetrack/features/timer/presentation/widgets/project_picker_sheet.dart'; + +class ProjectPickerChip extends StatelessWidget { + const ProjectPickerChip({ + super.key, + required this.project, + required this.onChanged, + this.enabled = true, + }); + + final Project? project; + final ValueChanged onChanged; + final bool enabled; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final hasProject = project != null; + + return ActionChip( + key: const Key('project_picker_chip'), + avatar: hasProject + ? CircleAvatar( + backgroundColor: Color(project!.colorValue), + radius: 10, + ) + : Icon(Icons.folder_outlined, size: 18, color: colorScheme.onSurfaceVariant), + label: Text(hasProject ? project!.name : 'Select project'), + onPressed: enabled + ? () async { + final selected = await ProjectPickerSheet.show(context); + if (selected != null) onChanged(selected); + } + : null, + backgroundColor: hasProject + ? colorScheme.secondaryContainer + : colorScheme.surfaceContainerHighest, + ); + } +} diff --git a/lib/features/timer/presentation/widgets/project_picker_sheet.dart b/lib/features/timer/presentation/widgets/project_picker_sheet.dart new file mode 100644 index 0000000..b034b3e --- /dev/null +++ b/lib/features/timer/presentation/widgets/project_picker_sheet.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:timetrack/features/projects/domain/project.dart'; +import 'package:timetrack/features/projects/domain/projects_provider.dart'; + +class ProjectPickerSheet extends ConsumerWidget { + const ProjectPickerSheet({super.key}); + + static Future show(BuildContext context) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (_) => const ProjectPickerSheet(), + ); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final projectsAsync = ref.watch(activeProjectsProvider); + + return DraggableScrollableSheet( + initialChildSize: 0.5, + minChildSize: 0.3, + maxChildSize: 0.85, + expand: false, + builder: (context, scrollController) => Column( + children: [ + const SizedBox(height: 8), + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.outlineVariant, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 16), + Text( + 'Select Project', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + Expanded( + child: projectsAsync.when( + data: (projects) => projects.isEmpty + ? const Center(child: Text('No projects yet.\nCreate one in the Projects tab.', textAlign: TextAlign.center)) + : ListView.builder( + controller: scrollController, + itemCount: projects.length, + itemBuilder: (context, index) { + final project = projects[index]; + return ListTile( + leading: CircleAvatar( + backgroundColor: Color(project.colorValue), + radius: 12, + ), + title: Text(project.name), + subtitle: project.description != null + ? Text(project.description!, maxLines: 1, overflow: TextOverflow.ellipsis) + : null, + onTap: () => Navigator.pop(context, project), + ); + }, + ), + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('Error: $e')), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/timer/presentation/widgets/quick_access_grid.dart b/lib/features/timer/presentation/widgets/quick_access_grid.dart new file mode 100644 index 0000000..5f835aa --- /dev/null +++ b/lib/features/timer/presentation/widgets/quick_access_grid.dart @@ -0,0 +1,156 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:timetrack/features/projects/domain/project.dart'; +import 'package:timetrack/features/timer/domain/frequent_projects_provider.dart'; +import 'package:timetrack/features/timer/domain/timer_notifier.dart'; +import 'package:timetrack/features/timer/domain/timer_state.dart'; + +class QuickAccessGrid extends ConsumerWidget { + const QuickAccessGrid({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final projects = ref.watch(frequentProjectsProvider); + if (projects.isEmpty) return const SizedBox.shrink(); + + final timerState = ref.watch(timerNotifierProvider); + final runningProject = + timerState is TimerRunning ? timerState.project : null; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Quick Access', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + letterSpacing: 0.8, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: projects.map((project) { + return _ProjectChip( + project: project, + isRunning: runningProject?.id == project.id, + onTap: () => _onChipTap(context, ref, project, runningProject), + ); + }).toList(), + ), + ], + ), + ); + } + + Future _onChipTap( + BuildContext context, + WidgetRef ref, + Project tapped, + Project? runningProject, + ) async { + final notifier = ref.read(timerNotifierProvider.notifier); + + if (runningProject?.id == tapped.id) { + // Toggle: stop the running timer + await notifier.stop(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('${tapped.name} gestoppt'), + duration: const Duration(seconds: 2), + ), + ); + } + } else if (runningProject != null) { + // Switch: stop old, start new + final stoppedName = runningProject.name; + await notifier.start(tapped); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('$stoppedName gestoppt, ${tapped.name} gestartet'), + duration: const Duration(seconds: 2), + ), + ); + } + } else { + // Start fresh + await notifier.start(tapped); + } + } +} + +class _ProjectChip extends StatelessWidget { + const _ProjectChip({ + required this.project, + required this.isRunning, + required this.onTap, + }); + + final Project project; + final bool isRunning; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final projectColor = Color(project.colorValue); + final colorScheme = Theme.of(context).colorScheme; + + final backgroundColor = isRunning + ? projectColor.withValues(alpha: 0.15) + : colorScheme.surfaceContainerHighest; + + final borderColor = isRunning ? projectColor : Colors.transparent; + + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(20), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: borderColor, + width: isRunning ? 2 : 0, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: projectColor, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 120), + child: Text( + project.name, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontWeight: + isRunning ? FontWeight.w600 : FontWeight.normal, + color: isRunning + ? projectColor + : colorScheme.onSurfaceVariant, + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/timer/presentation/widgets/timer_controls.dart b/lib/features/timer/presentation/widgets/timer_controls.dart new file mode 100644 index 0000000..c9375cd --- /dev/null +++ b/lib/features/timer/presentation/widgets/timer_controls.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:timetrack/features/projects/domain/project.dart'; +import 'package:timetrack/features/timer/domain/timer_state.dart'; +import 'package:timetrack/features/timer/domain/timer_notifier.dart'; + +class TimerControls extends ConsumerWidget { + const TimerControls({ + super.key, + required this.state, + required this.selectedProject, + }); + + final TimerState state; + final Project? selectedProject; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final notifier = ref.read(timerNotifierProvider.notifier); + final isRunning = state is TimerRunning; + final colorScheme = Theme.of(context).colorScheme; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (!isRunning) + FilledButton.icon( + key: const Key('start_button'), + onPressed: selectedProject == null + ? null + : () => notifier.start(selectedProject!), + icon: const Icon(Icons.play_arrow_rounded), + label: const Text('Start'), + style: FilledButton.styleFrom( + minimumSize: const Size(160, 56), + textStyle: Theme.of(context).textTheme.titleMedium, + ), + ) + else ...[ + FilledButton.icon( + key: const Key('stop_button'), + onPressed: () => notifier.stop(), + icon: const Icon(Icons.stop_rounded), + label: const Text('Stop'), + style: FilledButton.styleFrom( + backgroundColor: colorScheme.error, + foregroundColor: colorScheme.onError, + minimumSize: const Size(160, 56), + textStyle: Theme.of(context).textTheme.titleMedium, + ), + ), + const SizedBox(height: 12), + TextButton.icon( + key: const Key('discard_button'), + onPressed: () => _confirmDiscard(context, notifier), + icon: const Icon(Icons.delete_outline), + label: const Text('Discard'), + style: TextButton.styleFrom( + foregroundColor: colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ); + } + + Future _confirmDiscard( + BuildContext context, + TimerNotifier notifier, + ) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Discard entry?'), + content: const Text('The current time entry will be deleted.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Discard'), + ), + ], + ), + ); + if (confirmed == true) await notifier.discard(); + } +} diff --git a/lib/features/timer/presentation/widgets/timer_display.dart b/lib/features/timer/presentation/widgets/timer_display.dart new file mode 100644 index 0000000..4f02115 --- /dev/null +++ b/lib/features/timer/presentation/widgets/timer_display.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; + +class TimerDisplay extends StatelessWidget { + const TimerDisplay({super.key, required this.elapsed}); + + final Duration elapsed; + + @override + Widget build(BuildContext context) { + final h = elapsed.inHours.toString().padLeft(2, '0'); + final m = (elapsed.inMinutes % 60).toString().padLeft(2, '0'); + final s = (elapsed.inSeconds % 60).toString().padLeft(2, '0'); + + return Text( + '$h:$m:$s', + style: Theme.of(context).textTheme.displayLarge?.copyWith( + fontFeatures: const [FontFeature.tabularFigures()], + fontWeight: FontWeight.w300, + letterSpacing: 4, + ), + ); + } +} diff --git a/lib/features/timer/presentation/widgets/today_summary_card.dart b/lib/features/timer/presentation/widgets/today_summary_card.dart new file mode 100644 index 0000000..fd15271 --- /dev/null +++ b/lib/features/timer/presentation/widgets/today_summary_card.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:timetrack/features/entries/domain/entries_provider.dart'; + +class TodaySummaryCard extends ConsumerWidget { + const TodaySummaryCard({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final now = DateTime.now(); + final todayStart = DateTime(now.year, now.month, now.day); + final todayEnd = todayStart.add(const Duration(days: 1)); + + final entriesAsync = ref.watch( + entriesByDateRangeProvider(rangeFrom: todayStart, rangeTo: todayEnd), + ); + + return entriesAsync.when( + data: (entries) { + final completed = entries.where((e) => e.endTime != null).toList(); + final total = completed.fold( + 0, + (sum, e) => sum + (e.durationSeconds ?? 0), + ); + final totalDuration = Duration(seconds: total); + + return Card( + margin: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + const Icon(Icons.today_outlined), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Today', + style: Theme.of(context).textTheme.labelMedium, + ), + Text( + _formatDuration(totalDuration), + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const Spacer(), + Text( + '${completed.length} entr${completed.length == 1 ? 'y' : 'ies'}', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + ); + }, + loading: () => const SizedBox.shrink(), + error: (e, st) => const SizedBox.shrink(), + ); + } + + String _formatDuration(Duration d) { + final h = d.inHours; + final m = d.inMinutes % 60; + if (h > 0) return '${h}h ${m}m'; + if (m > 0) return '${m}m'; + return '0m'; + } +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..fff64e2 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,9 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:timetrack/app.dart'; + +void main() { + WidgetsFlutterBinding.ensureInitialized(); + runApp(const ProviderScope(child: App())); +} diff --git a/linux/.gitignore b/linux/.gitignore new file mode 100644 index 0000000..d3896c9 --- /dev/null +++ b/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt new file mode 100644 index 0000000..f1af1d5 --- /dev/null +++ b/linux/CMakeLists.txt @@ -0,0 +1,128 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "timetrack") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.timetrack.timetrack") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/linux/flutter/CMakeLists.txt b/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..d5bd016 --- /dev/null +++ b/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..4c0025f --- /dev/null +++ b/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,19 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) sqlite3_flutter_libs_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin"); + sqlite3_flutter_libs_plugin_register_with_registrar(sqlite3_flutter_libs_registrar); + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); +} diff --git a/linux/flutter/generated_plugin_registrant.h b/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..e0f0a47 --- /dev/null +++ b/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..75d875c --- /dev/null +++ b/linux/flutter/generated_plugins.cmake @@ -0,0 +1,26 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + sqlite3_flutter_libs + url_launcher_linux +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/linux/runner/CMakeLists.txt b/linux/runner/CMakeLists.txt new file mode 100644 index 0000000..e97dabc --- /dev/null +++ b/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/linux/runner/main.cc b/linux/runner/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc new file mode 100644 index 0000000..ebcb5f7 --- /dev/null +++ b/linux/runner/my_application.cc @@ -0,0 +1,148 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Called when first Flutter frame received. +static void first_frame_cb(MyApplication* self, FlView* view) { + gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); +} + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "timetrack"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "timetrack"); + } + + gtk_window_set_default_size(window, 1280, 720); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments( + project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + GdkRGBA background_color; + // Background defaults to black, override it here if necessary, e.g. #00000000 + // for transparent. + gdk_rgba_parse(&background_color, "#000000"); + fl_view_set_background_color(view, &background_color); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + // Show the window when Flutter renders. + // Requires the view to be realized so we can start rendering. + g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), + self); + gtk_widget_realize(GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, + gchar*** arguments, + int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = + my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, "flags", + G_APPLICATION_NON_UNIQUE, nullptr)); +} diff --git a/linux/runner/my_application.h b/linux/runner/my_application.h new file mode 100644 index 0000000..db16367 --- /dev/null +++ b/linux/runner/my_application.h @@ -0,0 +1,21 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, + my_application, + MY, + APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..69b3819 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,1127 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f + url: "https://pub.dev" + source: hosted + version: "85.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: f4ad0fea5f102201015c9aae9d93bc02f75dd9491529a8c21f88d17a8523d44c + url: "https://pub.dev" + source: hosted + version: "7.6.0" + analyzer_plugin: + dependency: transitive + description: + name: analyzer_plugin + sha256: a5ab7590c27b779f3d4de67f31c4109dbe13dd7339f86461a6f2a8ab2594d8ce + url: "https://pub.dev" + source: hosted + version: "0.13.4" + archive: + dependency: transitive + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" + source: hosted + version: "4.0.9" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + barcode: + dependency: transitive + description: + name: barcode + sha256: "7b6729c37e3b7f34233e2318d866e8c48ddb46c1f7ad01ff7bb2a8de1da2b9f4" + url: "https://pub.dev" + source: hosted + version: "2.2.9" + bidi: + dependency: transitive + description: + name: bidi + sha256: "77f475165e94b261745cf1032c751e2032b8ed92ccb2bf5716036db79320637d" + url: "https://pub.dev" + source: hosted + version: "2.0.13" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: "51dc711996cbf609b90cbe5b335bbce83143875a9d58e4b5c6d3c4f684d3dda7" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: fd754058c342243718d5171a95f352cfc9fcf0cba8cfa26df67cb13a5836db78 + url: "https://pub.dev" + source: hosted + version: "4.1.2" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + sha256: ee4257b3f20c0c90e72ed2b57ad637f694ccba48839a821e87db762548c22a62 + url: "https://pub.dev" + source: hosted + version: "2.5.4" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "382a4d649addbfb7ba71a3631df0ec6a45d5ab9b098638144faf27f02778eb53" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + sha256: "85fbbb1036d576d966332a3f5ce83f2ce66a40bea1a94ad2d5fc29a19a0d3792" + url: "https://pub.dev" + source: hosted + version: "9.1.2" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: "34e4067d30ce212937df995f03b69992eea683539ceeac7f679a1f1eba055b56" + url: "https://pub.dev" + source: hosted + version: "8.12.6" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + charcode: + dependency: transitive + description: + name: charcode + sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a + url: "https://pub.dev" + source: hosted + version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" + url: "https://pub.dev" + source: hosted + version: "4.11.1" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51" + url: "https://pub.dev" + source: hosted + version: "0.3.5+4" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + csv: + dependency: "direct main" + description: + name: csv + sha256: c6aa2679b2a18cb57652920f674488d89712efaf4d3fdf2e537215b35fc19d6c + url: "https://pub.dev" + source: hosted + version: "6.0.0" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.dev" + source: hosted + version: "1.0.9" + custom_lint_core: + dependency: transitive + description: + name: custom_lint_core + sha256: "31110af3dde9d29fb10828ca33f1dce24d2798477b167675543ce3d208dee8be" + url: "https://pub.dev" + source: hosted + version: "0.7.5" + custom_lint_visitor: + dependency: transitive + description: + name: custom_lint_visitor + sha256: "4a86a0d8415a91fbb8298d6ef03e9034dc8e323a599ddc4120a0e36c433983a2" + url: "https://pub.dev" + source: hosted + version: "1.0.0+7.7.0" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + drift: + dependency: "direct main" + description: + name: drift + sha256: "540cf382a3bfa99b76e51514db5b0ebcd81ce3679b7c1c9cb9478ff3735e47a1" + url: "https://pub.dev" + source: hosted + version: "2.28.2" + drift_dev: + dependency: "direct dev" + description: + name: drift_dev + sha256: "68c138e884527d2bd61df2ade276c3a144df84d1adeb0ab8f3196b5afe021bd4" + url: "https://pub.dev" + source: hosted + version: "2.28.0" + equatable: + dependency: transitive + description: + name: equatable + sha256: "3bce007a596ff8b3119c45d68aaef631272537c03d30e5d4534dd24bf4c5eaa2" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + fl_chart: + dependency: "direct main" + description: + name: fl_chart + sha256: "5276944c6ffc975ae796569a826c38a62d2abcf264e26b88fa6f482e107f4237" + url: "https://pub.dev" + source: hosted + version: "0.70.2" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + freezed: + dependency: "direct dev" + description: + name: freezed + sha256: "59a584c24b3acdc5250bb856d0d3e9c0b798ed14a4af1ddb7dc1c7b41df91c9c" + url: "https://pub.dev" + source: hosted + version: "2.5.8" + freezed_annotation: + dependency: "direct main" + description: + name: freezed_annotation + sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2 + url: "https://pub.dev" + source: hosted + version: "2.4.4" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3 + url: "https://pub.dev" + source: hosted + version: "14.8.1" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce + url: "https://pub.dev" + source: hosted + version: "4.8.0" + intl: + dependency: "direct main" + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + jni: + dependency: transitive + description: + name: jni + sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f + url: "https://pub.dev" + source: hosted + version: "1.0.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" + json_annotation: + dependency: "direct main" + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" + json_serializable: + dependency: "direct dev" + description: + name: json_serializable + sha256: c50ef5fc083d5b5e12eef489503ba3bf5ccc899e487d691584699b4bdefeea8c + url: "https://pub.dev" + source: hosted + version: "6.9.5" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + mocktail: + dependency: "direct dev" + description: + name: mocktail + sha256: "5e1bf53cc7baa8062a33b84424deb61513858ea05c601b8509e683815b5914aa" + url: "https://pub.dev" + source: hosted + version: "1.0.5" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed" + url: "https://pub.dev" + source: hosted + version: "9.4.1" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: "direct main" + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + pdf: + dependency: "direct main" + description: + name: pdf + sha256: e47a275b267873d5944ad5f5ff0dcc7ac2e36c02b3046a0ffac9b72fd362c44b + url: "https://pub.dev" + source: hosted + version: "3.12.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + posix: + dependency: transitive + description: + name: posix + sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" + url: "https://pub.dev" + source: hosted + version: "6.5.0" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + qr: + dependency: transitive + description: + name: qr + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + recase: + dependency: transitive + description: + name: recase + sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213 + url: "https://pub.dev" + source: hosted + version: "4.1.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + riverpod_analyzer_utils: + dependency: transitive + description: + name: riverpod_analyzer_utils + sha256: "837a6dc33f490706c7f4632c516bcd10804ee4d9ccc8046124ca56388715fdf3" + url: "https://pub.dev" + source: hosted + version: "0.5.9" + riverpod_annotation: + dependency: "direct main" + description: + name: riverpod_annotation + sha256: e14b0bf45b71326654e2705d462f21b958f987087be850afd60578fcd502d1b8 + url: "https://pub.dev" + source: hosted + version: "2.6.1" + riverpod_generator: + dependency: "direct dev" + description: + name: riverpod_generator + sha256: "120d3310f687f43e7011bb213b90a436f1bbc300f0e4b251a72c39bccb017a4f" + url: "https://pub.dev" + source: hosted + version: "2.6.4" + share_plus: + dependency: "direct main" + description: + name: share_plus + sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da + url: "https://pub.dev" + source: hosted + version: "10.1.4" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b + url: "https://pub.dev" + source: hosted + version: "5.0.2" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.dev" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53 + url: "https://pub.dev" + source: hosted + version: "2.4.23" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + source_helper: + dependency: transitive + description: + name: source_helper + sha256: a447acb083d3a5ef17f983dd36201aeea33fedadb3228fa831f2f0c92f0f3aca + url: "https://pub.dev" + source: hosted + version: "1.3.7" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + sqlite3: + dependency: "direct dev" + description: + name: sqlite3 + sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2" + url: "https://pub.dev" + source: hosted + version: "2.9.4" + sqlite3_flutter_libs: + dependency: "direct main" + description: + name: sqlite3_flutter_libs + sha256: eeb9e3a45207649076b808f8a5a74d68770d0b7f26ccef6d5f43106eee5375ad + url: "https://pub.dev" + source: hosted + version: "0.5.42" + sqlparser: + dependency: transitive + description: + name: sqlparser + sha256: "57090342af1ce32bb499aa641f4ecdd2d6231b9403cea537ac059e803cc20d67" + url: "https://pub.dev" + source: hosted + version: "0.41.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.dev" + source: hosted + version: "1.0.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + url: "https://pub.dev" + source: hosted + version: "0.7.10" + timing: + dependency: transitive + description: + name: timing + sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" + uuid: + dependency: "direct main" + description: + name: uuid + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + url: "https://pub.dev" + source: hosted + version: "4.5.3" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.dev" + source: hosted + version: "15.2.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.11.5 <4.0.0" + flutter: ">=3.38.4" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..bdcd142 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,64 @@ +name: timetrack +description: "Time tracking app — capture and analyse working hours." +publish_to: 'none' +version: 0.1.0+1 + +environment: + sdk: ^3.11.5 + +dependencies: + flutter: + sdk: flutter + flutter_localizations: + sdk: flutter + + # State management + flutter_riverpod: ^2.6.1 + riverpod_annotation: ^2.3.5 + + # Navigation + go_router: ^14.6.3 + + # Database + drift: ^2.22.1 + sqlite3_flutter_libs: ^0.5.28 + path_provider: ^2.1.5 + path: ^1.9.1 + + # Models + freezed_annotation: ^2.4.4 + json_annotation: ^4.9.0 + + # Charts + fl_chart: ^0.70.2 + + # Export + share_plus: ^10.0.3 + pdf: ^3.11.1 + csv: ^6.0.0 + + # Utilities + intl: ^0.20.2 + uuid: ^4.5.1 + cupertino_icons: ^1.0.8 + shared_preferences: ^2.3.3 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + + # Code generation + build_runner: ^2.4.13 + drift_dev: ^2.22.1 + freezed: ^2.5.7 + riverpod_generator: ^2.4.3 + json_serializable: ^6.8.0 + + # Testing + mocktail: ^1.0.4 + sqlite3: ^2.9.4 + +flutter: + uses-material-design: true + generate: true diff --git a/scripts/apk_retention.sh b/scripts/apk_retention.sh new file mode 100755 index 0000000..cc83963 --- /dev/null +++ b/scripts/apk_retention.sh @@ -0,0 +1,175 @@ +#!/usr/bin/env bash +# apk_retention.sh — Build-Historie-Bereinigung für Timetrack APKs +# +# Retention-Strategie: +# Aktuelle Minor-Version → letzte 15 APKs nach Build-Nummer behalten +# Ältere Minor-Versionen → je eine APK behalten (höchste Build-Nummer = Release-Marker) +# +# Verwendung: +# bash scripts/apk_retention.sh [--dir build/apk] [--keep 15] [--dry-run] +# +# Dateiname-Format: timetrack-{VERSION}+{BUILD}.apk +# Beispiele: timetrack-0.1.0+1.apk timetrack-0.2.0+3.apk + +set -euo pipefail + +# ── Defaults ────────────────────────────────────────────────────────────────── +APK_DIR="build/apk" +KEEP=15 +DRY_RUN=false + +# ── Argument-Parsing ────────────────────────────────────────────────────────── +while [[ $# -gt 0 ]]; do + case "$1" in + --dir) APK_DIR="$2"; shift 2 ;; + --keep) KEEP="$2"; shift 2 ;; + --dry-run) DRY_RUN=true; shift ;; + *) echo "Unbekanntes Argument: $1" >&2; exit 1 ;; + esac +done + +# ── Verzeichnis prüfen ──────────────────────────────────────────────────────── +if [[ ! -d "$APK_DIR" ]]; then + echo "apk_retention: Verzeichnis '$APK_DIR' nicht gefunden — übersprungen." + exit 0 +fi + +# ── Hilfsfunktionen ─────────────────────────────────────────────────────────── + +# Extrahiert "MAJOR.MINOR" aus "timetrack-0.1.0+5.apk" → "0.1" +minor_version() { + local name="$1" + # Dateiname: timetrack-0.1.0+5.apk → Extrahiere "0.1.0" → nehme "0.1" + local ver + ver=$(echo "$name" | grep -oP '(?<=timetrack-)\d+\.\d+\.\d+(?=\+)') || true + if [[ -z "$ver" ]]; then echo ""; return; fi + # Nur Major.Minor + echo "$ver" | cut -d. -f1-2 +} + +# Extrahiert Build-Nummer aus "timetrack-0.1.0+5.apk" → 5 +build_number() { + local name="$1" + echo "$name" | grep -oP '(?<=\+)\d+(?=\.apk)' || echo "0" +} + +# Löscht eine APK + zugehörige .sha256-Datei +delete_apk() { + local path="$1" + local sha="${path}.sha256" + if [[ "$DRY_RUN" == "true" ]]; then + echo " [DRY-RUN] würde löschen: $(basename "$path")" + [[ -f "$sha" ]] && echo " [DRY-RUN] würde löschen: $(basename "$sha")" + else + rm -f "$path" + [[ -f "$sha" ]] && rm -f "$sha" + echo " gelöscht: $(basename "$path")" + fi +} + +# ── APKs einlesen (ohne Symlinks) ───────────────────────────────────────────── +declare -a all_apks=() +while IFS= read -r -d '' f; do + name=$(basename "$f") + # Nur versionierte APKs — kein "timetrack.apk"-Symlink + if [[ "$name" == timetrack-*.apk ]]; then + all_apks+=("$name") + fi +done < <(find "$APK_DIR" -maxdepth 1 -name "timetrack-*.apk" -not -type l -print0 2>/dev/null) + +total=${#all_apks[@]} +if [[ $total -eq 0 ]]; then + echo "apk_retention: Keine APKs gefunden in '$APK_DIR' — nichts zu tun." + exit 0 +fi + +echo "apk_retention: $total APKs gefunden in '$APK_DIR'" +echo "apk_retention: Retention: letzte $KEEP in aktueller Minor-Version, je 1 pro älterer" +echo "" + +# ── Minor-Versionen ermitteln ───────────────────────────────────────────────── +declare -A minor_map # minor → kommaseparierte Liste von Dateinamen +for name in "${all_apks[@]}"; do + mv=$(minor_version "$name") + if [[ -z "$mv" ]]; then continue; fi + if [[ -v minor_map["$mv"] ]]; then + minor_map["$mv"]="${minor_map[$mv]} $name" + else + minor_map["$mv"]="$name" + fi +done + +# Sortierte Liste der Minor-Versionen (höchste = aktuell) +declare -a sorted_minors=() +while IFS= read -r mv; do + sorted_minors+=("$mv") +done < <(printf '%s\n' "${!minor_map[@]}" | sort -t. -k1,1n -k2,2n) + +current_minor="${sorted_minors[-1]}" +echo " Aktuelle Minor-Version : $current_minor" +echo " Ältere Minor-Versionen : ${sorted_minors[*]::${#sorted_minors[@]}-1}" +echo "" + +# ── Bereinigung: Ältere Minor-Versionen ────────────────────────────────────── +for mv in "${sorted_minors[@]}"; do + [[ "$mv" == "$current_minor" ]] && continue + + # Alle APKs dieser Minor-Version nach Build-Nummer sortieren + declare -a mv_apks=() + for name in ${minor_map[$mv]}; do + mv_apks+=("$name") + done + + # Sortieren nach Build-Nummer (numerisch) + declare -a sorted_mv=() + while IFS= read -r line; do + sorted_mv+=("$line") + done < <(printf '%s\n' "${mv_apks[@]}" | sort -t+ -k2,2n) + + keeper="${sorted_mv[-1]}" + echo " Minor $mv: behalte $keeper (Release-Marker)" + + for name in "${sorted_mv[@]}"; do + [[ "$name" == "$keeper" ]] && continue + delete_apk "$APK_DIR/$name" + done + + unset mv_apks sorted_mv +done + +# ── Bereinigung: Aktuelle Minor-Version ────────────────────────────────────── +declare -a cur_apks=() +for name in ${minor_map[$current_minor]}; do + cur_apks+=("$name") +done + +# Sortieren nach Build-Nummer (numerisch) +declare -a sorted_cur=() +while IFS= read -r line; do + sorted_cur+=("$line") +done < <(printf '%s\n' "${cur_apks[@]}" | sort -t+ -k2,2n) + +cur_count=${#sorted_cur[@]} +echo " Aktuelle Minor $current_minor: $cur_count APKs, behalte letzte $KEEP" + +if [[ $cur_count -gt $KEEP ]]; then + to_delete=$(( cur_count - KEEP )) + for (( i=0; i + + + + + Timetrack — Android App + + + +
+
+
+

Timetrack

+
+

Time tracking — offline-first, cross-platform

+
+
+
Version
+
{{.Version}}
+
+
+
Größe
+
{{.SizeMB}} MB
+
+
+
Build
+
{{.BuildDate}}
+
+
+
Plattform
+
Android
+
+
+ + APK herunterladen + +

+ Nach dem Download: Datei öffnen → Installieren.
+ Einmalig: Einstellungen → Sicherheit →
+ Browser/Dateimanager als Installationsquelle erlauben. +

+
+
🔒 SHA256-Prüfsumme
+
{{.SHA256}}
+ +
+
+ + +
+
+
+
+
+ +
+ + +` + +// ── Data structures ─────────────────────────────────────────────────────────── + +type pageData struct { + Version string + SizeMB string + BuildDate string + SHA256 string + Host string +} + +type server struct { + apkPath string + sha256 string + sizeMB string + date string + version string + etag string + debug bool + tpl *template.Template +} + +// ── APK discovery ───────────────────────────────────────────────────────────── + +// apkVersion parses version info from filenames like timetrack-0.1.0+3.apk +// Returns the full version string (e.g. "0.1.0+3") or the filename stem. +var apkRe = regexp.MustCompile(`timetrack-([0-9]+\.[0-9]+\.[0-9]+\+[0-9]+)\.apk$`) + +func parseVersion(name string) string { + m := apkRe.FindStringSubmatch(name) + if len(m) == 2 { + return m[1] + } + return strings.TrimSuffix(name, ".apk") +} + +// findLatestAPK returns the path to the newest APK in dir. +// Prefers timetrack.apk symlink, falls back to newest versioned file. +func findLatestAPK(dir string) (string, error) { + symlink := filepath.Join(dir, "timetrack.apk") + if _, err := os.Stat(symlink); err == nil { + resolved, err := filepath.EvalSymlinks(symlink) + if err == nil { + return resolved, nil + } + } + + entries, err := os.ReadDir(dir) + if err != nil { + return "", fmt.Errorf("cannot read dir %s: %w", dir, err) + } + + var apks []string + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".apk") && e.Name() != "timetrack.apk" { + apks = append(apks, e.Name()) + } + } + if len(apks) == 0 { + return "", fmt.Errorf("no .apk files found in %s", dir) + } + sort.Strings(apks) + return filepath.Join(dir, apks[len(apks)-1]), nil +} + +// ── SHA256 sidecar ──────────────────────────────────────────────────────────── + +func readSHA256(apkPath string) string { + data, err := os.ReadFile(apkPath + ".sha256") + if err != nil { + return "" + } + fields := strings.Fields(string(data)) + if len(fields) > 0 { + return fields[0] + } + return "" +} + +// ── Local IP ────────────────────────────────────────────────────────────────── + +func localIP() string { + conn, err := net.Dial("udp", "8.8.8.8:80") + if err != nil { + return "127.0.0.1" + } + defer conn.Close() + return conn.LocalAddr().(*net.UDPAddr).IP.String() +} + +// ── QR code ─────────────────────────────────────────────────────────────────── + +func printQR(url string) { + if _, err := exec.LookPath("qrencode"); err != nil { + fmt.Println(" (qrencode nicht verfügbar — URL manuell eingeben)") + return + } + cmd := exec.Command("qrencode", "-t", "UTF8", url) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + _ = cmd.Run() +} + +// ── HTTP handlers ───────────────────────────────────────────────────────────── + +func (s *server) noCache(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") + w.Header().Set("Pragma", "no-cache") + w.Header().Set("Expires", "0") + w.Header().Set("ETag", s.etag) + next.ServeHTTP(w, r) + }) +} + +func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) { + host := r.Host + if host == "" { + host = "localhost" + } + data := pageData{ + Version: s.version, + SizeMB: s.sizeMB, + BuildDate: s.date, + SHA256: s.sha256, + Host: host, + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := s.tpl.Execute(w, data); err != nil && s.debug { + fmt.Fprintf(os.Stderr, "template error: %v\n", err) + } +} + +func (s *server) handleDownload(w http.ResponseWriter, r *http.Request) { + f, err := os.Open(s.apkPath) + if err != nil { + http.Error(w, "APK not found", http.StatusNotFound) + return + } + defer f.Close() + + info, err := f.Stat() + if err != nil { + http.Error(w, "stat error", http.StatusInternalServerError) + return + } + + name := filepath.Base(s.apkPath) + w.Header().Set("Content-Type", "application/vnd.android.package-archive") + w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`) + w.Header().Set("Content-Length", strconv.FormatInt(info.Size(), 10)) + w.Header().Set("Accept-Ranges", "bytes") + + buf := make([]byte, 256*1024) + io.CopyBuffer(w, f, buf) //nolint:errcheck +} + +func (s *server) handleSHA256(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ //nolint:errcheck + "sha256": s.sha256, + "filename": filepath.Base(s.apkPath), + }) +} + +// ── Main ────────────────────────────────────────────────────────────────────── + +func main() { + dir := flag.String("dir", "", "Verzeichnis mit den Dateien (Standard: build/apk oder build/web)") + port := flag.Int("port", 8888, "HTTP-Port") + debug := flag.Bool("debug", false, "Ausführliche Request-Logs aktivieren") + web := flag.Bool("web", false, "Web-Modus: Flutter-Web-App als Static Files ausliefern") + flag.Parse() + + if *web { + runWebMode(*dir, *port, *debug) + } else { + runAPKMode(*dir, *port, *debug) + } +} + +// ── Web mode ────────────────────────────────────────────────────────────────── + +func runWebMode(dir string, port int, debug bool) { + if dir == "" { + dir = "build/web" + } + absDir, err := filepath.Abs(dir) + if err != nil || !dirExists(absDir) { + fatalf("FEHLER: Verzeichnis '%s' nicht gefunden. Zuerst 'make build_web' ausführen.\n", dir) + } + + ip := localIP() + addr := fmt.Sprintf("0.0.0.0:%d", port) + url := fmt.Sprintf("http://%s:%d", ip, port) + + printWebBanner(url, absDir, debug) + printQR(url) + printWebFooter() + + var handler http.Handler = http.FileServer(http.Dir(absDir)) + if debug { + handler = logMiddleware(handler) + } + + if err := http.ListenAndServe(addr, handler); err != nil { + fatalf("Server-Fehler: %v\n", err) + } +} + +// ── APK mode ────────────────────────────────────────────────────────────────── + +func runAPKMode(dir string, port int, debug bool) { + if dir == "" { + dir = "build/apk" + } + absDir, err := filepath.Abs(dir) + if err != nil || !dirExists(absDir) { + fatalf("FEHLER: Verzeichnis '%s' nicht gefunden. Zuerst 'make build_android' ausführen.\n", dir) + } + + apkPath, err := findLatestAPK(absDir) + if err != nil { + fatalf("FEHLER: %v\n → Zuerst 'make build_android' ausführen.\n", err) + } + + info, _ := os.Stat(apkPath) + sizeMB := fmt.Sprintf("%.1f", float64(info.Size())/(1024*1024)) + buildDate := time.Unix(info.ModTime().Unix(), 0).Format("02.01.2006") + sha256 := readSHA256(apkPath) + version := parseVersion(filepath.Base(apkPath)) + etag := fmt.Sprintf(`"%d"`, info.ModTime().Unix()) + + tpl, err := template.New("page").Parse(htmlTpl) + if err != nil { + fatalf("FEHLER: Template-Fehler: %v\n", err) + } + + srv := &server{ + apkPath: apkPath, + sha256: sha256, + sizeMB: sizeMB, + date: buildDate, + version: version, + etag: etag, + debug: debug, + tpl: tpl, + } + + ip := localIP() + addr := fmt.Sprintf("0.0.0.0:%d", port) + url := fmt.Sprintf("http://%s:%d", ip, port) + + printBanner(filepath.Base(apkPath), float64(info.Size())/(1024*1024), url, sha256, debug) + printQR(url) + printFooter() + + mux := http.NewServeMux() + mux.HandleFunc("/", srv.handleIndex) + mux.HandleFunc("/download", srv.handleDownload) + mux.HandleFunc("/sha256", srv.handleSHA256) + + var handler http.Handler = srv.noCache(mux) + if debug { + handler = logMiddleware(handler) + } + + if err := http.ListenAndServe(addr, handler); err != nil { + fatalf("Server-Fehler: %v\n", err) + } +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +func dirExists(path string) bool { + info, err := os.Stat(path) + return err == nil && info.IsDir() +} + +func fatalf(format string, args ...any) { + fmt.Fprintf(os.Stderr, format, args...) + os.Exit(1) +} + +func printBanner(apkName string, sizeMB float64, url, sha256 string, dbg bool) { + sep := strings.Repeat("═", 50) + fmt.Println() + fmt.Println(sep) + fmt.Println(" Timetrack APK — WLAN-Download") + fmt.Println(sep) + fmt.Printf(" Datei : %s (%.1f MB)\n", apkName, sizeMB) + fmt.Printf(" URL : %s\n", url) + if dbg { + fmt.Println(" Modus : DEBUG (ausführliche Request-Logs)") + } + if sha256 != "" { + prefix := sha256 + if len(prefix) > 16 { + prefix = prefix[:16] + } + fmt.Printf(" SHA256: %s…\n", prefix) + } else { + fmt.Println(" SHA256: keine .sha256-Datei gefunden") + } + fmt.Println() + fmt.Println(" QR-Code scannen:") + fmt.Println() +} + +func printFooter() { + sep := strings.Repeat("═", 50) + fmt.Println(sep) + fmt.Println(" Handy: QR scannen → Seite öffnen → Herunterladen") + fmt.Println(" SHA256-Prüfung direkt im Browser verfügbar") + fmt.Println(" Stoppen mit Ctrl+C") + fmt.Println(sep) + fmt.Println() +} + +func logMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + next.ServeHTTP(w, r) + fmt.Printf("[%s] %s %s %s\n", + time.Now().Format("15:04:05"), + r.Method, r.URL.Path, + time.Since(start).Round(time.Millisecond), + ) + }) +} + +func printWebBanner(url, dir string, dbg bool) { + sep := strings.Repeat("═", 50) + fmt.Println() + fmt.Println(sep) + fmt.Println(" Timetrack Web — WLAN-Zugriff") + fmt.Println(sep) + fmt.Printf(" Verzeichnis : %s\n", dir) + fmt.Printf(" URL : %s\n", url) + if dbg { + fmt.Println(" Modus : DEBUG (ausführliche Request-Logs)") + } + fmt.Println() + fmt.Println(" QR-Code scannen:") + fmt.Println() +} + +func printWebFooter() { + sep := strings.Repeat("═", 50) + fmt.Println(sep) + fmt.Println(" Handy: QR scannen → App direkt im Browser öffnen") + fmt.Println(" Stoppen mit Ctrl+C") + fmt.Println(sep) + fmt.Println() +} diff --git a/scripts/serve/serve b/scripts/serve/serve new file mode 100755 index 0000000..7d6b082 Binary files /dev/null and b/scripts/serve/serve differ diff --git a/test/core/database/app_database_test.dart b/test/core/database/app_database_test.dart new file mode 100644 index 0000000..e9e3cc7 --- /dev/null +++ b/test/core/database/app_database_test.dart @@ -0,0 +1,70 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:timetrack/core/database/app_database.dart'; +import '../../helpers/sqlite_test_helper.dart'; + +AppDatabase createTestDb() => AppDatabase(NativeDatabase.memory()); + +void main() { + setUpAll(configureSqliteForTests); + late AppDatabase db; + + setUp(() => db = createTestDb()); + tearDown(() => db.close()); + + group('AppDatabase', () { + test('schema creates without error', () async { + // Simply opening the DB triggers onCreate migration + final projects = await db.projectsDao.watchAll().first; + expect(projects, isEmpty); + }); + }); + + group('ProjectsDao', () { + test('insert and retrieve a project', () async { + await db.projectsDao.insertProject( + ProjectsCompanion.insert(name: 'Test Project', colorValue: 0xFF2563EB), + ); + + final all = await db.projectsDao.watchAll().first; + expect(all, hasLength(1)); + expect(all.first.name, equals('Test Project')); + expect(all.first.colorValue, equals(0xFF2563EB)); + }); + + test('watchActive excludes archived projects', () async { + await db.projectsDao.insertProject( + ProjectsCompanion.insert(name: 'Active', colorValue: 0xFF00FF00), + ); + final id = await db.projectsDao.insertProject( + ProjectsCompanion.insert(name: 'Archived', colorValue: 0xFFFF0000), + ); + await db.projectsDao.archiveProject(id); + + final active = await db.projectsDao.watchActive().first; + expect(active, hasLength(1)); + expect(active.first.name, equals('Active')); + }); + + test('archiveProject sets archivedAt', () async { + final id = await db.projectsDao.insertProject( + ProjectsCompanion.insert(name: 'ToArchive', colorValue: 0xFF000000), + ); + await db.projectsDao.archiveProject(id); + + final project = await db.projectsDao.getById(id); + expect(project?.archivedAt, isNotNull); + }); + + test('deleteProject removes the row', () async { + final id = await db.projectsDao.insertProject( + ProjectsCompanion.insert(name: 'ToDelete', colorValue: 0xFF000000), + ); + await db.projectsDao.deleteProject(id); + + final all = await db.projectsDao.watchAll().first; + expect(all, isEmpty); + }); + }); +} diff --git a/test/features/entries/entries_repository_test.dart b/test/features/entries/entries_repository_test.dart new file mode 100644 index 0000000..ed0c071 --- /dev/null +++ b/test/features/entries/entries_repository_test.dart @@ -0,0 +1,125 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:timetrack/core/database/app_database.dart' as db; +import 'package:timetrack/features/entries/data/drift_entries_repository.dart'; +import 'package:timetrack/features/entries/domain/time_entry.dart'; +import 'package:timetrack/features/projects/data/drift_projects_repository.dart'; + +import '../../helpers/sqlite_test_helper.dart'; + +TimeEntry makeEntry(int projectId, DateTime start, {DateTime? endTime, bool openEntry = false}) { + if (openEntry) { + return TimeEntry( + id: 0, + projectId: projectId, + startTime: start, + tags: const [], + createdAt: start, + ); + } + final end = endTime ?? start.add(const Duration(hours: 1)); + return TimeEntry( + id: 0, + projectId: projectId, + startTime: start, + endTime: end, + durationSeconds: end.difference(start).inSeconds, + tags: const [], + createdAt: start, + ); +} + +void main() { + setUpAll(configureSqliteForTests); + + late db.AppDatabase database; + late DriftProjectsRepository projectsRepo; + late DriftTimeEntriesRepository entriesRepo; + + setUp(() { + database = db.AppDatabase(NativeDatabase.memory()); + projectsRepo = DriftProjectsRepository(database); + entriesRepo = DriftTimeEntriesRepository(database); + }); + + tearDown(() => database.close()); + + group('TimeEntriesRepository', () { + Future createProject() => + projectsRepo.create(name: 'Test', colorValue: 0xFF2563EB); + + test('create and watchAll', () async { + final pid = await createProject(); + final now = DateTime.now(); + await entriesRepo.create(makeEntry(pid, now)); + + final entries = await entriesRepo.watchAll().first; + expect(entries, hasLength(1)); + expect(entries.first.projectId, equals(pid)); + }); + + test('getActiveEntry returns open entry', () async { + final pid = await createProject(); + final now = DateTime.now(); + await entriesRepo.create(makeEntry(pid, now, openEntry: true)); + + final active = await entriesRepo.getActiveEntry(); + expect(active, isNotNull); + expect(active!.endTime, isNull); + }); + + test('getActiveEntry returns null when all entries complete', () async { + final pid = await createProject(); + final now = DateTime.now(); + await entriesRepo.create( + makeEntry(pid, now, endTime: now.add(const Duration(hours: 1))), + ); + final active = await entriesRepo.getActiveEntry(); + expect(active, isNull); + }); + + test('update modifies entry note', () async { + final pid = await createProject(); + final now = DateTime.now(); + await entriesRepo.create(makeEntry(pid, now)); + + final created = (await entriesRepo.watchAll().first).first; + await entriesRepo.update(created.copyWith(note: 'Updated note')); + + final updated = (await entriesRepo.watchAll().first).first; + expect(updated.note, equals('Updated note')); + }); + + test('delete removes entry', () async { + final pid = await createProject(); + final now = DateTime.now(); + final id = await entriesRepo.create(makeEntry(pid, now)); + + await entriesRepo.delete(id); + + final entries = await entriesRepo.watchAll().first; + expect(entries, isEmpty); + }); + + test('watchByDateRange filters correctly', () async { + final pid = await createProject(); + final base = DateTime(2026, 6, 15); + await entriesRepo.create( + makeEntry(pid, base, endTime: base.add(const Duration(hours: 1))), + ); + await entriesRepo.create( + makeEntry( + pid, + base.add(const Duration(days: 10)), + endTime: base.add(const Duration(days: 10, hours: 1)), + ), + ); + + final inRange = await entriesRepo + .watchByDateRange(DateTime(2026, 6, 14), DateTime(2026, 6, 16)) + .first; + expect(inRange, hasLength(1)); + }); + }); +} diff --git a/test/features/entries/entries_screen_test.dart b/test/features/entries/entries_screen_test.dart new file mode 100644 index 0000000..a255c71 --- /dev/null +++ b/test/features/entries/entries_screen_test.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'package:timetrack/features/entries/data/drift_entries_repository.dart'; +import 'package:timetrack/features/entries/data/entries_repository.dart'; +import 'package:timetrack/features/entries/domain/time_entry.dart'; +import 'package:timetrack/features/entries/presentation/entries_screen.dart'; +import 'package:timetrack/features/projects/data/drift_projects_repository.dart'; +import 'package:timetrack/features/projects/data/projects_repository.dart'; +import 'package:timetrack/features/projects/domain/project.dart'; + +class MockTimeEntriesRepository extends Mock implements TimeEntriesRepository {} +class MockProjectsRepository extends Mock implements ProjectsRepository {} + +TimeEntry makeEntry(int projectId, DateTime start) { + final end = start.add(const Duration(hours: 1)); + return TimeEntry( + id: 0, + projectId: projectId, + startTime: start, + endTime: end, + durationSeconds: 3600, + tags: const [], + createdAt: start, + ); +} + +void main() { + late MockTimeEntriesRepository mockEntries; + late MockProjectsRepository mockProjects; + + setUp(() { + mockEntries = MockTimeEntriesRepository(); + mockProjects = MockProjectsRepository(); + + when(() => mockEntries.watchByDateRange(any(), any())) + .thenAnswer((_) => Stream.value([])); + when(() => mockProjects.watchAll()) + .thenAnswer((_) => Stream.value([])); + when(() => mockProjects.watchActive()) + .thenAnswer((_) => Stream.value([])); + }); + + Widget buildTestApp() { + return ProviderScope( + overrides: [ + timeEntriesRepositoryProvider.overrideWithValue(mockEntries), + projectsRepositoryProvider.overrideWithValue(mockProjects), + ], + child: const MaterialApp(home: EntriesScreen()), + ); + } + + testWidgets('shows empty state when no entries', (tester) async { + await tester.pumpWidget(buildTestApp()); + await tester.pump(); + + expect(find.byKey(const Key('entries_empty')), findsOneWidget); + }); + + testWidgets('shows Add Entry button', (tester) async { + await tester.pumpWidget(buildTestApp()); + await tester.pump(); + + expect(find.byIcon(Icons.add), findsOneWidget); + }); + + testWidgets('shows date range filter chips', (tester) async { + await tester.pumpWidget(buildTestApp()); + await tester.pump(); + + expect(find.text('Today'), findsOneWidget); + expect(find.text('This week'), findsOneWidget); + expect(find.text('This month'), findsOneWidget); + }); + + testWidgets('renders entry tiles when entries exist', (tester) async { + final project = Project( + id: 1, + name: 'Alpha', + colorValue: 0xFF2563EB, + createdAt: DateTime(2026), + ); + when(() => mockProjects.watchAll()) + .thenAnswer((_) => Stream.value([project])); + + final now = DateTime(2026, 7, 12, 10); + when(() => mockEntries.watchByDateRange(any(), any())) + .thenAnswer((_) => Stream.value([makeEntry(1, now)])); + + await tester.pumpWidget(buildTestApp()); + await tester.pump(); + + expect(find.byKey(const Key('entry_tile_0')), findsOneWidget); + }); +} diff --git a/test/features/projects/projects_repository_test.dart b/test/features/projects/projects_repository_test.dart new file mode 100644 index 0000000..30a05db --- /dev/null +++ b/test/features/projects/projects_repository_test.dart @@ -0,0 +1,80 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:timetrack/core/database/app_database.dart'; +import 'package:timetrack/features/projects/data/drift_projects_repository.dart'; + +import '../../helpers/sqlite_test_helper.dart'; + +void main() { + setUpAll(configureSqliteForTests); + + late AppDatabase database; + late DriftProjectsRepository repo; + + setUp(() { + database = AppDatabase(NativeDatabase.memory()); + repo = DriftProjectsRepository(database); + }); + + tearDown(() => database.close()); + + group('ProjectsRepository', () { + test('create and watchAll', () async { + await repo.create(name: 'Alpha', colorValue: 0xFF0000FF); + await repo.create(name: 'Beta', colorValue: 0xFF00FF00); + + final projects = await repo.watchAll().first; + expect(projects, hasLength(2)); + expect(projects.map((p) => p.name), containsAll(['Alpha', 'Beta'])); + }); + + test('watchActive excludes archived', () async { + await repo.create(name: 'Active', colorValue: 0xFFFF0000); + final id = await repo.create(name: 'Archived', colorValue: 0xFF000000); + await repo.archive(id); + + final active = await repo.watchActive().first; + expect(active, hasLength(1)); + expect(active.first.name, equals('Active')); + }); + + test('getById returns correct project', () async { + final id = await repo.create(name: 'FindMe', colorValue: 0xFF123456); + final project = await repo.getById(id); + expect(project, isNotNull); + expect(project!.name, equals('FindMe')); + }); + + test('update modifies project', () async { + final id = await repo.create(name: 'Old', colorValue: 0xFF111111); + final project = (await repo.getById(id))!; + await repo.update(project.copyWith(name: 'New')); + + final updated = await repo.getById(id); + expect(updated!.name, equals('New')); + }); + + test('archive sets archivedAt', () async { + final id = await repo.create(name: 'ToArchive', colorValue: 0xFF222222); + await repo.archive(id); + final project = await repo.getById(id); + expect(project!.archivedAt, isNotNull); + }); + + test('unarchive clears archivedAt', () async { + final id = await repo.create(name: 'UnArchive', colorValue: 0xFF333333); + await repo.archive(id); + await repo.unarchive(id); + final project = await repo.getById(id); + expect(project!.archivedAt, isNull); + }); + + test('delete removes project', () async { + final id = await repo.create(name: 'Delete', colorValue: 0xFF444444); + await repo.delete(id); + final all = await repo.watchAll().first; + expect(all, isEmpty); + }); + }); +} diff --git a/test/features/projects/projects_screen_test.dart b/test/features/projects/projects_screen_test.dart new file mode 100644 index 0000000..c2fbea4 --- /dev/null +++ b/test/features/projects/projects_screen_test.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'package:timetrack/features/entries/data/drift_entries_repository.dart'; +import 'package:timetrack/features/entries/data/entries_repository.dart'; +import 'package:timetrack/features/projects/data/drift_projects_repository.dart'; +import 'package:timetrack/features/projects/data/projects_repository.dart'; +import 'package:timetrack/features/projects/domain/project.dart'; +import 'package:timetrack/features/projects/presentation/projects_screen.dart'; + +class MockProjectsRepository extends Mock implements ProjectsRepository {} +class MockTimeEntriesRepository extends Mock implements TimeEntriesRepository {} + +void main() { + late MockProjectsRepository mockProjects; + late MockTimeEntriesRepository mockEntries; + + setUp(() { + mockProjects = MockProjectsRepository(); + mockEntries = MockTimeEntriesRepository(); + + when(() => mockProjects.watchAll()) + .thenAnswer((_) => Stream.value([])); + when(() => mockProjects.watchActive()) + .thenAnswer((_) => Stream.value([])); + when(() => mockEntries.watchByDateRange(any(), any())) + .thenAnswer((_) => Stream.value([])); + }); + + Widget buildTestApp() { + return ProviderScope( + overrides: [ + projectsRepositoryProvider.overrideWithValue(mockProjects), + timeEntriesRepositoryProvider.overrideWithValue(mockEntries), + ], + child: const MaterialApp(home: ProjectsScreen()), + ); + } + + testWidgets('shows empty state on Active tab', (tester) async { + await tester.pumpWidget(buildTestApp()); + await tester.pump(); + + expect(find.byKey(const Key('active_empty')), findsOneWidget); + }); + + testWidgets('shows Add Project button', (tester) async { + await tester.pumpWidget(buildTestApp()); + await tester.pump(); + + expect(find.byIcon(Icons.add), findsOneWidget); + }); + + testWidgets('shows Active and Archived tabs', (tester) async { + await tester.pumpWidget(buildTestApp()); + await tester.pump(); + + expect(find.text('Active'), findsOneWidget); + expect(find.text('Archived'), findsOneWidget); + }); + + testWidgets('renders project tile when project exists', (tester) async { + final project = Project( + id: 1, + name: 'My Project', + colorValue: 0xFF2563EB, + createdAt: DateTime(2026), + ); + when(() => mockProjects.watchActive()) + .thenAnswer((_) => Stream.value([project])); + + await tester.pumpWidget(buildTestApp()); + await tester.pump(); + + expect(find.byKey(const Key('project_tile_1')), findsOneWidget); + expect(find.text('My Project'), findsOneWidget); + }); + + testWidgets('tap Add opens project form sheet', (tester) async { + await tester.pumpWidget(buildTestApp()); + await tester.pump(); + + await tester.tap(find.byIcon(Icons.add)); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('project_name_field')), findsOneWidget); + }); +} diff --git a/test/features/timer/timer_notifier_test.dart b/test/features/timer/timer_notifier_test.dart new file mode 100644 index 0000000..97c2e73 --- /dev/null +++ b/test/features/timer/timer_notifier_test.dart @@ -0,0 +1,158 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'package:timetrack/core/database/app_database.dart' as appdb; +import 'package:timetrack/core/database/daos/projects_dao.dart'; +import 'package:timetrack/features/entries/data/drift_entries_repository.dart'; +import 'package:timetrack/features/entries/data/entries_repository.dart'; +import 'package:timetrack/features/entries/domain/time_entry.dart'; +import 'package:timetrack/features/projects/domain/project.dart'; +import 'package:timetrack/features/timer/domain/timer_notifier.dart'; +import 'package:timetrack/features/timer/domain/timer_state.dart'; + +// Mocks +class MockTimeEntriesRepository extends Mock implements TimeEntriesRepository {} +class MockAppDatabase extends Mock implements appdb.AppDatabase {} +class MockProjectsDao extends Mock implements ProjectsDao {} + +class FakeTimeEntry extends Fake implements TimeEntry {} + +void main() { + setUpAll(() { + registerFallbackValue(FakeTimeEntry()); + }); + late MockTimeEntriesRepository mockRepo; + late MockAppDatabase mockDb; + late MockProjectsDao mockProjectsDao; + + final testProject = Project( + id: 1, + name: 'Test', + colorValue: 0xFF2563EB, + createdAt: DateTime(2026), + ); + + final activeEntry = TimeEntry( + id: 42, + projectId: 1, + startTime: DateTime(2026, 7, 1, 9), + tags: const [], + createdAt: DateTime(2026, 7, 1, 9), + ); + + setUp(() { + mockRepo = MockTimeEntriesRepository(); + mockDb = MockAppDatabase(); + mockProjectsDao = MockProjectsDao(); + + when(() => mockDb.projectsDao).thenReturn(mockProjectsDao); + // Default: no project found (safe fallback for restore) + when(() => mockProjectsDao.getById(any())) + .thenAnswer((_) async => null); + }); + + ProviderContainer buildContainer({TimeEntry? activeEntryOverride}) { + when(() => mockRepo.getActiveEntry()) + .thenAnswer((_) async => activeEntryOverride); + + return ProviderContainer( + overrides: [ + timeEntriesRepositoryProvider.overrideWithValue(mockRepo), + appdb.appDatabaseProvider.overrideWithValue(mockDb), + ], + ); + } + + group('TimerNotifier', () { + test('build starts idle when no active entry', () async { + final container = buildContainer(activeEntryOverride: null); + addTearDown(container.dispose); + + await Future.delayed(const Duration(milliseconds: 50)); + + final state = container.read(timerNotifierProvider); + expect(state, isA()); + }); + + test('build restores running state from DB active entry', () async { + // Override the default null-returning stub for id=1 + when(() => mockProjectsDao.getById(1)) + .thenAnswer((_) async => appdb.Project( + id: 1, + name: 'Test', + colorValue: 0xFF2563EB, + createdAt: DateTime(2026), + )); + + final container = buildContainer(activeEntryOverride: activeEntry); + addTearDown(container.dispose); + + // Trigger provider creation and keep it alive with a listener + container.listen(timerNotifierProvider, (prev, next) {}); + await Future.delayed(const Duration(milliseconds: 500)); + + final state = container.read(timerNotifierProvider); + expect(state, isA()); + expect((state as TimerRunning).entryId, equals(42)); + }); + + test('start transitions to running state', () async { + final container = buildContainer(activeEntryOverride: null); + addTearDown(container.dispose); + await Future.delayed(const Duration(milliseconds: 50)); + + when(() => mockRepo.create(any())).thenAnswer((_) async => 99); + + await container + .read(timerNotifierProvider.notifier) + .start(testProject); + + final state = container.read(timerNotifierProvider); + expect(state, isA()); + expect((state as TimerRunning).project.name, equals('Test')); + }); + + test('stop transitions to idle and saves entry', () async { + final container = buildContainer(activeEntryOverride: null); + addTearDown(container.dispose); + await Future.delayed(const Duration(milliseconds: 50)); + + when(() => mockRepo.create(any())).thenAnswer((_) async => 99); + // After start(), getActiveEntry should return the running entry + var callCount = 0; + when(() => mockRepo.getActiveEntry()).thenAnswer((_) async { + callCount++; + if (callCount == 1) return null; // initial restore check + return activeEntry; + }); + when(() => mockRepo.update(any())).thenAnswer((_) async {}); + + await container + .read(timerNotifierProvider.notifier) + .start(testProject); + await container.read(timerNotifierProvider.notifier).stop(); + + final state = container.read(timerNotifierProvider); + expect(state, isA()); + }); + + test('discard transitions to idle and deletes entry', () async { + final container = buildContainer(activeEntryOverride: null); + addTearDown(container.dispose); + await Future.delayed(const Duration(milliseconds: 50)); + + when(() => mockRepo.create(any())).thenAnswer((_) async => 99); + when(() => mockRepo.delete(any())).thenAnswer((_) async {}); + + await container + .read(timerNotifierProvider.notifier) + .start(testProject); + await container.read(timerNotifierProvider.notifier).discard(); + + final state = container.read(timerNotifierProvider); + expect(state, isA()); + verify(() => mockRepo.delete(99)).called(1); + }); + }); +} diff --git a/test/features/timer/timer_screen_test.dart b/test/features/timer/timer_screen_test.dart new file mode 100644 index 0000000..6e2b58f --- /dev/null +++ b/test/features/timer/timer_screen_test.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'package:timetrack/features/entries/data/entries_repository.dart'; +import 'package:timetrack/features/entries/data/drift_entries_repository.dart'; +import 'package:timetrack/features/projects/data/projects_repository.dart'; +import 'package:timetrack/features/projects/data/drift_projects_repository.dart'; +import 'package:timetrack/features/projects/domain/project.dart'; +import 'package:timetrack/features/timer/domain/timer_notifier.dart'; +import 'package:timetrack/features/timer/domain/timer_state.dart'; +import 'package:timetrack/features/timer/presentation/timer_screen.dart'; + +class MockTimeEntriesRepository extends Mock implements TimeEntriesRepository {} + +class MockProjectsRepository extends Mock implements ProjectsRepository {} + +Widget buildTestApp({List overrides = const []}) { + return ProviderScope( + overrides: overrides, + child: const MaterialApp(home: TimerScreen()), + ); +} + +void main() { + late MockTimeEntriesRepository mockEntries; + late MockProjectsRepository mockProjects; + + setUp(() { + mockEntries = MockTimeEntriesRepository(); + mockProjects = MockProjectsRepository(); + + when(() => mockEntries.getActiveEntry()).thenAnswer((_) async => null); + when(() => mockEntries.watchAll()).thenAnswer((_) => const Stream.empty()); + when( + () => mockEntries.watchByDateRange(any(), any()), + ).thenAnswer((_) => Stream.value([])); + when(() => mockProjects.watchAll()).thenAnswer((_) => Stream.value([])); + when(() => mockProjects.watchActive()).thenAnswer((_) => Stream.value([])); + }); + + List baseOverrides() => [ + timeEntriesRepositoryProvider.overrideWithValue(mockEntries), + projectsRepositoryProvider.overrideWithValue(mockProjects), + ]; + + testWidgets('idle state — START button visible, STOP hidden', (tester) async { + await tester.pumpWidget(buildTestApp(overrides: baseOverrides())); + await tester.pump(); + + expect(find.byKey(const Key('start_button')), findsOneWidget); + expect(find.byKey(const Key('stop_button')), findsNothing); + expect(find.byKey(const Key('discard_button')), findsNothing); + }); + + testWidgets('START button disabled without project selected', (tester) async { + await tester.pumpWidget(buildTestApp(overrides: baseOverrides())); + await tester.pump(); + + final startBtn = tester.widget( + find.byKey(const Key('start_button')), + ); + expect(startBtn.onPressed, isNull); + }); + + testWidgets('running state — STOP and DISCARD visible', (tester) async { + final project = Project( + id: 1, + name: 'Test', + colorValue: 0xFF2563EB, + createdAt: DateTime.now(), + ); + final runningState = TimerState.running( + entryId: 1, + startTime: DateTime.now().subtract(const Duration(minutes: 5)), + project: project, + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + ...baseOverrides(), + timerNotifierProvider + .overrideWith(() => _FakeTimerNotifier(runningState)), + ], + child: const MaterialApp(home: TimerScreen()), + ), + ); + await tester.pump(); + + expect(find.byKey(const Key('stop_button')), findsOneWidget); + expect(find.byKey(const Key('discard_button')), findsOneWidget); + expect(find.byKey(const Key('start_button')), findsNothing); + }); + + testWidgets('project picker chip is shown', (tester) async { + await tester.pumpWidget(buildTestApp(overrides: baseOverrides())); + await tester.pump(); + + expect(find.byKey(const Key('project_picker_chip')), findsOneWidget); + }); +} + +class _FakeTimerNotifier extends TimerNotifier { + _FakeTimerNotifier(this._fixedState); + final TimerState _fixedState; + + @override + TimerState build() => _fixedState; +} diff --git a/test/helpers/sqlite_test_helper.dart b/test/helpers/sqlite_test_helper.dart new file mode 100644 index 0000000..f408712 --- /dev/null +++ b/test/helpers/sqlite_test_helper.dart @@ -0,0 +1,16 @@ +import 'dart:ffi'; + +import 'package:sqlite3/open.dart'; + +/// Call this in [setUpAll] for any test that uses a Drift NativeDatabase. +/// On Linux the unversioned `libsqlite3.so` symlink may be absent; +/// this falls back to the versioned `.so.0` that ships with the OS. +void configureSqliteForTests() { + open.overrideFor(OperatingSystem.linux, () { + try { + return DynamicLibrary.open('libsqlite3.so'); + } catch (_) { + return DynamicLibrary.open('libsqlite3.so.0'); + } + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..760a99f --- /dev/null +++ b/test/widget_test.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:timetrack/app.dart'; + +void main() { + testWidgets('App smoke test', (WidgetTester tester) async { + await tester.pumpWidget(const ProviderScope(child: App())); + // App renders without crashing + expect(find.byType(MaterialApp), findsOneWidget); + }); +} diff --git a/web/favicon.png b/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/web/favicon.png differ diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/web/icons/Icon-192.png differ diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/web/icons/Icon-512.png differ diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/web/icons/Icon-maskable-192.png differ diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/web/icons/Icon-maskable-512.png differ diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..37f1a41 --- /dev/null +++ b/web/index.html @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + timetrack + + + + + + + diff --git a/web/manifest.json b/web/manifest.json new file mode 100644 index 0000000..bf5852d --- /dev/null +++ b/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "timetrack", + "short_name": "timetrack", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +}