diff --git a/README.md b/README.md index 23fad13..a4d4d74 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,51 @@ # timetracker -A new Flutter project. +A cross-platform time tracker built with Flutter for the frontend and Rust for the backend logic. Communication is handled via `flutter_rust_bridge`, and data is stored locally on the device using SQLite. The user interface adapts to the native look-and-feel of Android and iOS thanks to `flutter_platform_widgets`. -## Getting Started +## Features -This project is a starting point for a Flutter application. +* **Time Tracking:** Start and stop time tracking entries. +* **Tag Management:** + * Create new tags. + * List all tags. + * Edit tag names (via swipe gesture). + * Delete tags (via swipe gesture, includes confirmation). Associated time entries will have their tag set to NULL. +* **Local Storage:** All data is securely stored in a local SQLite database on the device (managed by Rust). +* **Reporting:** + * Display time entries filtered by period (Day, Week, Month, Year) and optionally by tag. + * Total duration display for the filtered period. + * Visualization using charts (`fl_chart`): + * **Bar chart:** Distribution of daily time spent per tag for the selected period. + * List view for report entries. + * **Pull-to-Refresh:** Manually refresh report data. + * **Swipe-to-Delete:** Delete individual time entries directly from the report list via swipe gesture (with confirmation). +* **Platform-Adaptive UI:** Uses `flutter_platform_widgets` to provide a native appearance on Android (Material Design) and iOS (Cupertino). -A few resources to get you started if this is your first Flutter project: +## Technologies -- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) -- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) +* **Frontend:** Flutter / Dart + * State Management: `provider` + * UI Adaptation: `flutter_platform_widgets` + * Charts: `fl_chart` + * Swipe Actions: `flutter_slidable` + * Intl: `intl` +* **Backend:** Rust + * Database: SQLite (via `rusqlite`) + * Error Handling: `anyhow` + * Logging: `log` +* **Bridge:** `flutter_rust_bridge` +* **Build:** Cargo, Flutter Build Tools, Gradle (Android), Xcode (iOS) -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. +## Architecture + +The app follows a clear separation between UI and logic: + +`Flutter UI (Widgets)` <-> `Dart Service Layer (TimeTrackingService)` <-> `flutter_rust_bridge (Generated Bindings)` <-> `Rust API (api.rs)` <-> `Rust DB Logic (database.rs)` <-> `SQLite Database` + +## Contributing + +Contributions are welcome! Please create an issue to report bugs or suggest new features. Pull requests are also welcome. + +## License + +[MIT License] diff --git a/lib/main.dart b/lib/main.dart index f5c288c..7743e0e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,6 +3,7 @@ import 'dart:developer'; import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:path_provider/path_provider.dart'; import 'package:timetracker/screens/main_screen.dart'; import 'package:timetracker/src/rust/api.dart'; @@ -68,6 +69,12 @@ class MyApp extends StatelessWidget { Widget build(BuildContext context) { return MaterialApp( title: 'Flutter Rust Time Tracker', + supportedLocales: const [Locale('de', 'DE'), Locale('en', '')], + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], theme: ThemeData(primarySwatch: Colors.blue, useMaterial3: true), home: const InitializerWidget(), ); diff --git a/lib/screens/report_screen.dart b/lib/screens/report_screen.dart index b24c37e..dcae7ae 100644 --- a/lib/screens/report_screen.dart +++ b/lib/screens/report_screen.dart @@ -1,9 +1,11 @@ import 'dart:developer'; import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; import 'package:timetracker/src/rust/api.dart'; @@ -56,7 +58,7 @@ extension ReportDataFormatting on ReportData { } } -enum ReportPeriod { day, week, month, year } +enum ReportPeriod { day, week, month, year, custom } class ReportScreen extends StatefulWidget { const ReportScreen({super.key}); @@ -68,6 +70,8 @@ class ReportScreen extends StatefulWidget { class _ReportScreenState extends State { final DateTime _selectedDate = DateTime.now(); ReportPeriod _selectedPeriod = ReportPeriod.day; + DateTime _customStartDate = DateTime.now(); + DateTime _customEndDate = DateTime.now(); Tag? _selectedTag; ReportData? _reportData; bool _isLoadingReport = false; @@ -85,6 +89,9 @@ class _ReportScreenState extends State { @override void initState() { super.initState(); + final now = DateTime.now(); + _customStartDate = DateTime(now.year, now.month, now.day); + _customEndDate = DateTime(now.year, now.month, now.day); _generateReport(); } @@ -330,7 +337,7 @@ class _ReportScreenState extends State { ); } - (DateTime, DateTime) _calculateDateRange() { + (DateTime, DateTime) _calculateDateRange(ReportPeriod period) { final nowLocal = DateTime.now().toLocal(); final startOfDay = DateTime(nowLocal.year, nowLocal.month, nowLocal.day); @@ -352,6 +359,13 @@ class _ReportScreenState extends State { final startOfYear = DateTime(nowLocal.year, 1, 1); final endOfYear = DateTime(nowLocal.year + 1, 1, 1); return (startOfYear, endOfYear); + case ReportPeriod.custom: + final endExclusive = DateTime( + _customEndDate.year, + _customEndDate.month, + _customEndDate.day, + ).add(const Duration(days: 1)); + return (_customStartDate, endExclusive); } } @@ -363,12 +377,33 @@ class _ReportScreenState extends State { }); final timeService = context.read(); - final (start, end) = _calculateDateRange(); final tagId = _selectedTag?.id; - log("Generating report for TagID: $tagId, Start: $start, End: $end"); + late DateTime reportStartDate; + late DateTime reportEndDateExclusive; - final data = await timeService.flGetReport(tagId, start, end); + if (_selectedPeriod == ReportPeriod.custom) { + reportStartDate = _customStartDate; + reportEndDateExclusive = DateTime( + _customEndDate.year, + _customEndDate.month, + _customEndDate.day, + ).add(const Duration(days: 1)); + } else { + final (start, end) = _calculateDateRange(_selectedPeriod); + reportStartDate = start; + reportEndDateExclusive = end; + } + + log( + "Generating report for Period: $_selectedPeriod, TagID: $tagId, Start: $reportStartDate, End (Exclusive): $reportEndDateExclusive", + ); + + final data = await timeService.flGetReport( + tagId, + reportStartDate, + reportEndDateExclusive, + ); if (mounted) { setState(() { @@ -378,6 +413,29 @@ class _ReportScreenState extends State { } } + // Future _generateReport() async { + // if (!mounted) return; + // setState(() { + // _isLoadingReport = true; + // _reportData = null; + // }); + + // final timeService = context.read(); + // final (start, end) = _calculateDateRange(); + // final tagId = _selectedTag?.id; + + // log("Generating report for TagID: $tagId, Start: $start, End: $end"); + + // final data = await timeService.flGetReport(tagId, start, end); + + // if (mounted) { + // setState(() { + // _reportData = data; + // _isLoadingReport = false; + // }); + // } + // } + @override Widget build(BuildContext context) { final tags = context.watch().tags; @@ -405,7 +463,7 @@ class _ReportScreenState extends State { ), ], ) - : _buildReportView(), + : _buildReportView(_reportData!), ), ), ], @@ -415,33 +473,71 @@ class _ReportScreenState extends State { Widget _buildFilterControls(List tagOptions) { final DateFormat formatter = DateFormat('dd.MM.yyyy'); - final (start, end) = _calculateDateRange(); + final String dateRangeButtonText = + '${formatter.format(_customStartDate)} - ${formatter.format(_customEndDate)}'; return Padding( padding: const EdgeInsets.all(8.0), child: Wrap( spacing: 8.0, runSpacing: 4.0, + alignment: WrapAlignment.start, + crossAxisAlignment: WrapCrossAlignment.center, children: [ - DropdownButton( - value: _selectedPeriod, - onChanged: (ReportPeriod? newValue) { - if (newValue != null) { - setState(() { - _selectedPeriod = newValue; - }); - _generateReport(); - } - }, - items: - ReportPeriod.values.map((ReportPeriod period) { - return DropdownMenuItem( - value: period, - child: PlatformText( - period.toString().split('.').last.toUpperCase(), - ), - ); - }).toList(), + PlatformWidget( + material: + (_, __) => DropdownButton( + value: _selectedPeriod, + items: [ + ...ReportPeriod.values.map((period) { + String text; + switch (period) { + case ReportPeriod.day: + text = 'Tag'; + break; + case ReportPeriod.week: + text = 'Woche'; + break; + case ReportPeriod.month: + text = 'Monat'; + break; + case ReportPeriod.year: + text = 'Jahr'; + break; + case ReportPeriod.custom: + text = 'Benutzerdefiniert'; + break; + } + return DropdownMenuItem( + value: period, + child: PlatformText(text), + ); + }), + ], + onChanged: (ReportPeriod? newValue) { + if (newValue != null && newValue != _selectedPeriod) { + setState(() { + _selectedPeriod = newValue; + if (_selectedPeriod != ReportPeriod.custom) { + _generateReport(); + } else { + // Optional: Direkt den Picker öffnen, wenn "Custom" gewählt wird? + // WidgetsBinding.instance.addPostFrameCallback((_) => _selectDateRange()); + } + }); + } + }, + ), + // TODO: Cupertino Dropdown Alternative (komplexer, oft wird Button+Picker genutzt) + // Fürs Erste verwenden wir auch auf iOS das Material Dropdown + cupertino: + (_, __) => CupertinoButton( + padding: EdgeInsets.zero, + child: Text(_selectedPeriod.toString().split('.').last), + onPressed: () { + /* Hier müsste ein Picker geöffnet werden */ + }, + ), ), DropdownButton( @@ -462,23 +558,135 @@ class _ReportScreenState extends State { }).toList(), ), - Chip( - label: PlatformText( - '${formatter.format(start)} - ${formatter.format(end.subtract(const Duration(seconds: 1)))}', + if (_selectedPeriod == ReportPeriod.custom) + PlatformElevatedButton( + onPressed: _selectDateRange, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(FontAwesomeIcons.calendar), // PlatformIcons(context)), + const SizedBox(width: 8), + PlatformText(dateRangeButtonText), + ], + ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), ), - ), ], ), ); } - Widget _buildReportView() { + // Widget _buildFilterControls(List tagOptions) { + // final DateFormat formatter = DateFormat('dd.MM.yyyy'); + // final (start, end) = _calculateDateRange(); + + // return Padding( + // padding: const EdgeInsets.all(8.0), + // child: Wrap( + // spacing: 8.0, + // runSpacing: 4.0, + // alignment: WrapAlignment.center, + // children: [ + // DropdownButton( + // value: _selectedPeriod, + // onChanged: (ReportPeriod? newValue) { + // if (newValue != null) { + // setState(() { + // _selectedPeriod = newValue; + // }); + // _generateReport(); + // } + // }, + // items: + // ReportPeriod.values.map((ReportPeriod period) { + // return DropdownMenuItem( + // value: period, + // child: PlatformText( + // period.toString().split('.').last.toUpperCase(), + // ), + // ); + // }).toList(), + // ), + + // DropdownButton( + // value: _selectedTag, + // hint: PlatformText("Alle Tags"), + // onChanged: (Tag? newValue) { + // setState(() { + // _selectedTag = newValue; + // }); + // _generateReport(); + // }, + // items: + // tagOptions.map((Tag? tag) { + // return DropdownMenuItem( + // value: tag, + // child: PlatformText(tag?.name ?? "Alle Tags"), + // ); + // }).toList(), + // ), + + // Chip( + // label: PlatformText( + // '${formatter.format(start)} - ${formatter.format(end.subtract(const Duration(seconds: 1)))}', + // ), + // ), + // ], + // ), + // ); + // } + + Future _selectDateRange() async { + final DateTimeRange initialRange = DateTimeRange( + start: _customStartDate, + end: _customEndDate, + ); + + final DateTimeRange? picked = await showDateRangePicker( + context: context, + locale: const Locale('de', 'DE'), + initialDateRange: initialRange, + firstDate: DateTime(2020), + lastDate: DateTime.now().add(const Duration(days: 365)), + helpText: 'Zeitraum auswählen', + cancelText: 'Abbrechen', + confirmText: 'Ok', + saveText: 'Speichern', + ); + + if (picked != null) { + if (picked.start != _customStartDate || + picked.end != _customEndDate || + _selectedPeriod != ReportPeriod.custom) { + setState(() { + _customStartDate = DateTime( + picked.start.year, + picked.start.month, + picked.start.day, + ); + _customEndDate = DateTime( + picked.end.year, + picked.end.month, + picked.end.day, + ); + _selectedPeriod = ReportPeriod.custom; + }); + _generateReport(); + } + } + } + + Widget _buildReportView(ReportData reportData) { if (_reportData == null) { return Center( child: PlatformText('Keine Reportdaten geladen oder Fehler.'), ); } - final (startDate, endDate) = _calculateDateRange(); + final (startDate, endDate) = _calculateDateRange( + _selectedPeriod == ReportPeriod.custom + ? ReportPeriod.custom + : _selectedPeriod, + ); return ListView( children: [ diff --git a/pubspec.lock b/pubspec.lock index eb05bdd..8f22e82 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -13,10 +13,10 @@ packages: dependency: transitive description: name: async - sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63 + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" url: "https://pub.dev" source: hosted - version: "2.12.0" + version: "2.13.0" boolean_selector: dependency: transitive description: @@ -77,10 +77,10 @@ packages: dependency: transitive description: name: fake_async - sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc" + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" url: "https://pub.dev" source: hosted - version: "1.3.2" + version: "1.3.3" ffi: dependency: "direct main" description: @@ -123,6 +123,11 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" flutter_platform_widgets: dependency: "direct main" description: @@ -182,10 +187,10 @@ packages: dependency: transitive description: name: leak_tracker - sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec + sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" url: "https://pub.dev" source: hosted - version: "10.0.8" + version: "10.0.9" leak_tracker_flutter_testing: dependency: transitive description: @@ -410,10 +415,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14" + sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 url: "https://pub.dev" source: hosted - version: "14.3.1" + version: "15.0.0" web: dependency: transitive description: @@ -426,10 +431,10 @@ packages: dependency: transitive description: name: webdriver - sha256: "3d773670966f02a646319410766d3b5e1037efb7f07cc68f844d5e06cd4d61c8" + sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade" url: "https://pub.dev" source: hosted - version: "3.0.4" + version: "3.1.0" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index b4bb4df..e250767 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: timetracker description: "A simple timetracking app" -publish_to: 'none' # Remove this line if you wish to publish to pub.dev +publish_to: "none" # Remove this line if you wish to publish to pub.dev version: 1.0.0+1 @@ -10,6 +10,8 @@ environment: dependencies: flutter: sdk: flutter + flutter_localizations: + sdk: flutter cupertino_icons: ^1.0.8 rust_lib_timetracker: @@ -18,6 +20,7 @@ dependencies: ffi: ^2.1.0 path_provider: ^2.1.1 intl: ^0.20.2 + # intl: ^0.19.0 provider: ^6.1.1 fl_chart: ^0.70.2 flutter_platform_widgets: ^8.0.0 @@ -33,6 +36,4 @@ dev_dependencies: sdk: flutter flutter: - uses-material-design: true - diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..7190a60 --- /dev/null +++ b/renovate.json @@ -0,0 +1,3 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json" +}