Compare commits

...

5 commits
v0.3.0 ... main

6 changed files with 313 additions and 54 deletions

View file

@ -1,16 +1,51 @@
# timetracker # 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) * **Frontend:** Flutter / Dart
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) * 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 ## Architecture
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference. 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]

View file

@ -3,6 +3,7 @@ import 'dart:developer';
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:timetracker/screens/main_screen.dart'; import 'package:timetracker/screens/main_screen.dart';
import 'package:timetracker/src/rust/api.dart'; import 'package:timetracker/src/rust/api.dart';
@ -68,6 +69,12 @@ class MyApp extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialApp( return MaterialApp(
title: 'Flutter Rust Time Tracker', 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), theme: ThemeData(primarySwatch: Colors.blue, useMaterial3: true),
home: const InitializerWidget(), home: const InitializerWidget(),
); );

View file

@ -1,9 +1,11 @@
import 'dart:developer'; import 'dart:developer';
import 'package:fl_chart/fl_chart.dart'; import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
import 'package:flutter_slidable/flutter_slidable.dart'; import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:timetracker/src/rust/api.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 { class ReportScreen extends StatefulWidget {
const ReportScreen({super.key}); const ReportScreen({super.key});
@ -68,6 +70,8 @@ class ReportScreen extends StatefulWidget {
class _ReportScreenState extends State<ReportScreen> { class _ReportScreenState extends State<ReportScreen> {
final DateTime _selectedDate = DateTime.now(); final DateTime _selectedDate = DateTime.now();
ReportPeriod _selectedPeriod = ReportPeriod.day; ReportPeriod _selectedPeriod = ReportPeriod.day;
DateTime _customStartDate = DateTime.now();
DateTime _customEndDate = DateTime.now();
Tag? _selectedTag; Tag? _selectedTag;
ReportData? _reportData; ReportData? _reportData;
bool _isLoadingReport = false; bool _isLoadingReport = false;
@ -85,6 +89,9 @@ class _ReportScreenState extends State<ReportScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
final now = DateTime.now();
_customStartDate = DateTime(now.year, now.month, now.day);
_customEndDate = DateTime(now.year, now.month, now.day);
_generateReport(); _generateReport();
} }
@ -330,7 +337,7 @@ class _ReportScreenState extends State<ReportScreen> {
); );
} }
(DateTime, DateTime) _calculateDateRange() { (DateTime, DateTime) _calculateDateRange(ReportPeriod period) {
final nowLocal = DateTime.now().toLocal(); final nowLocal = DateTime.now().toLocal();
final startOfDay = DateTime(nowLocal.year, nowLocal.month, nowLocal.day); final startOfDay = DateTime(nowLocal.year, nowLocal.month, nowLocal.day);
@ -352,6 +359,13 @@ class _ReportScreenState extends State<ReportScreen> {
final startOfYear = DateTime(nowLocal.year, 1, 1); final startOfYear = DateTime(nowLocal.year, 1, 1);
final endOfYear = DateTime(nowLocal.year + 1, 1, 1); final endOfYear = DateTime(nowLocal.year + 1, 1, 1);
return (startOfYear, endOfYear); 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<ReportScreen> {
}); });
final timeService = context.read<TimeTrackingService>(); final timeService = context.read<TimeTrackingService>();
final (start, end) = _calculateDateRange();
final tagId = _selectedTag?.id; 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) { if (mounted) {
setState(() { setState(() {
@ -378,6 +413,29 @@ class _ReportScreenState extends State<ReportScreen> {
} }
} }
// Future<void> _generateReport() async {
// if (!mounted) return;
// setState(() {
// _isLoadingReport = true;
// _reportData = null;
// });
// final timeService = context.read<TimeTrackingService>();
// 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final tags = context.watch<TimeTrackingService>().tags; final tags = context.watch<TimeTrackingService>().tags;
@ -405,7 +463,7 @@ class _ReportScreenState extends State<ReportScreen> {
), ),
], ],
) )
: _buildReportView(), : _buildReportView(_reportData!),
), ),
), ),
], ],
@ -415,33 +473,71 @@ class _ReportScreenState extends State<ReportScreen> {
Widget _buildFilterControls(List<Tag?> tagOptions) { Widget _buildFilterControls(List<Tag?> tagOptions) {
final DateFormat formatter = DateFormat('dd.MM.yyyy'); final DateFormat formatter = DateFormat('dd.MM.yyyy');
final (start, end) = _calculateDateRange(); final String dateRangeButtonText =
'${formatter.format(_customStartDate)} - ${formatter.format(_customEndDate)}';
return Padding( return Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: Wrap( child: Wrap(
spacing: 8.0, spacing: 8.0,
runSpacing: 4.0, runSpacing: 4.0,
alignment: WrapAlignment.start,
crossAxisAlignment: WrapCrossAlignment.center,
children: [ children: [
DropdownButton<ReportPeriod>( PlatformWidget(
value: _selectedPeriod, material:
onChanged: (ReportPeriod? newValue) { (_, __) => DropdownButton<ReportPeriod>(
if (newValue != null) { value: _selectedPeriod,
setState(() { items: [
_selectedPeriod = newValue; ...ReportPeriod.values.map((period) {
}); String text;
_generateReport(); switch (period) {
} case ReportPeriod.day:
}, text = 'Tag';
items: break;
ReportPeriod.values.map((ReportPeriod period) { case ReportPeriod.week:
return DropdownMenuItem<ReportPeriod>( text = 'Woche';
value: period, break;
child: PlatformText( case ReportPeriod.month:
period.toString().split('.').last.toUpperCase(), text = 'Monat';
), break;
); case ReportPeriod.year:
}).toList(), text = 'Jahr';
break;
case ReportPeriod.custom:
text = 'Benutzerdefiniert';
break;
}
return DropdownMenuItem<ReportPeriod>(
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<Tag?>( DropdownButton<Tag?>(
@ -462,23 +558,135 @@ class _ReportScreenState extends State<ReportScreen> {
}).toList(), }).toList(),
), ),
Chip( if (_selectedPeriod == ReportPeriod.custom)
label: PlatformText( PlatformElevatedButton(
'${formatter.format(start)} - ${formatter.format(end.subtract(const Duration(seconds: 1)))}', 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<Tag?> 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<ReportPeriod>(
// value: _selectedPeriod,
// onChanged: (ReportPeriod? newValue) {
// if (newValue != null) {
// setState(() {
// _selectedPeriod = newValue;
// });
// _generateReport();
// }
// },
// items:
// ReportPeriod.values.map((ReportPeriod period) {
// return DropdownMenuItem<ReportPeriod>(
// value: period,
// child: PlatformText(
// period.toString().split('.').last.toUpperCase(),
// ),
// );
// }).toList(),
// ),
// DropdownButton<Tag?>(
// value: _selectedTag,
// hint: PlatformText("Alle Tags"),
// onChanged: (Tag? newValue) {
// setState(() {
// _selectedTag = newValue;
// });
// _generateReport();
// },
// items:
// tagOptions.map((Tag? tag) {
// return DropdownMenuItem<Tag?>(
// value: tag,
// child: PlatformText(tag?.name ?? "Alle Tags"),
// );
// }).toList(),
// ),
// Chip(
// label: PlatformText(
// '${formatter.format(start)} - ${formatter.format(end.subtract(const Duration(seconds: 1)))}',
// ),
// ),
// ],
// ),
// );
// }
Future<void> _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) { if (_reportData == null) {
return Center( return Center(
child: PlatformText('Keine Reportdaten geladen oder Fehler.'), child: PlatformText('Keine Reportdaten geladen oder Fehler.'),
); );
} }
final (startDate, endDate) = _calculateDateRange(); final (startDate, endDate) = _calculateDateRange(
_selectedPeriod == ReportPeriod.custom
? ReportPeriod.custom
: _selectedPeriod,
);
return ListView( return ListView(
children: [ children: [

View file

@ -13,10 +13,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: async name: async
sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63 sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.12.0" version: "2.13.0"
boolean_selector: boolean_selector:
dependency: transitive dependency: transitive
description: description:
@ -77,10 +77,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: fake_async name: fake_async
sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc" sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.2" version: "1.3.3"
ffi: ffi:
dependency: "direct main" dependency: "direct main"
description: description:
@ -123,6 +123,11 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.0.0" version: "5.0.0"
flutter_localizations:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_platform_widgets: flutter_platform_widgets:
dependency: "direct main" dependency: "direct main"
description: description:
@ -182,10 +187,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker name: leak_tracker
sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "10.0.8" version: "10.0.9"
leak_tracker_flutter_testing: leak_tracker_flutter_testing:
dependency: transitive dependency: transitive
description: description:
@ -410,10 +415,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: vm_service name: vm_service
sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14" sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "14.3.1" version: "15.0.0"
web: web:
dependency: transitive dependency: transitive
description: description:
@ -426,10 +431,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: webdriver name: webdriver
sha256: "3d773670966f02a646319410766d3b5e1037efb7f07cc68f844d5e06cd4d61c8" sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.4" version: "3.1.0"
xdg_directories: xdg_directories:
dependency: transitive dependency: transitive
description: description:

View file

@ -1,6 +1,6 @@
name: timetracker name: timetracker
description: "A simple timetracking app" 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 version: 1.0.0+1
@ -10,6 +10,8 @@ environment:
dependencies: dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
flutter_localizations:
sdk: flutter
cupertino_icons: ^1.0.8 cupertino_icons: ^1.0.8
rust_lib_timetracker: rust_lib_timetracker:
@ -18,6 +20,7 @@ dependencies:
ffi: ^2.1.0 ffi: ^2.1.0
path_provider: ^2.1.1 path_provider: ^2.1.1
intl: ^0.20.2 intl: ^0.20.2
# intl: ^0.19.0
provider: ^6.1.1 provider: ^6.1.1
fl_chart: ^0.70.2 fl_chart: ^0.70.2
flutter_platform_widgets: ^8.0.0 flutter_platform_widgets: ^8.0.0
@ -33,6 +36,4 @@ dev_dependencies:
sdk: flutter sdk: flutter
flutter: flutter:
uses-material-design: true uses-material-design: true

3
renovate.json Normal file
View file

@ -0,0 +1,3 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
}