Compare commits
8 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d41cedb5f0 | |||
| b3b8e4b4a8 | |||
|
|
e73e178d08 | ||
| 169669315c | |||
| 9fe11e4796 | |||
| 4eed56f155 | |||
| 055b402c81 | |||
| ebc4cdf754 |
15 changed files with 818 additions and 210 deletions
53
README.md
53
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]
|
||||
|
|
|
|||
24
changelog.md
Normal file
24
changelog.md
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [unreleased]
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- Add swipe action to refresh report screen
|
||||
|
||||
## [0.2.0] - 2025-04-16
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- Add update and delete functionality to tags and improve report screen
|
||||
|
||||
## [0.1.0] - 2025-04-09
|
||||
|
||||
### 🚜 Refactor
|
||||
|
||||
- Clean up
|
||||
- Perform some clean up
|
||||
|
||||
<!-- generated by git-cliff -->
|
||||
|
|
@ -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,15 +69,13 @@ class MyApp extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Flutter Rust Time Tracker',
|
||||
theme: ThemeData(
|
||||
primarySwatch: Colors.blue,
|
||||
useMaterial3: true,
|
||||
// Optional: Theme für BottomNavigationBar anpassen
|
||||
// bottomNavigationBarTheme: const BottomNavigationBarThemeData(
|
||||
// selectedItemColor: Colors.deepPurple,
|
||||
// unselectedItemColor: Colors.grey,
|
||||
// ),
|
||||
),
|
||||
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(),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
|
|||
import 'package:timetracker/screens/home_screen.dart';
|
||||
import 'package:timetracker/screens/tags_screen.dart';
|
||||
import 'package:timetracker/screens/report_screen.dart';
|
||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||
|
||||
class MainScreen extends StatefulWidget {
|
||||
const MainScreen({super.key});
|
||||
|
|
@ -37,8 +38,8 @@ class _MainScreenState extends State<MainScreen> {
|
|||
|
||||
items: [
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(context.platformIcons.clockSolid),
|
||||
activeIcon: Icon(context.platformIcons.clockSolid),
|
||||
icon: FaIcon(FontAwesomeIcons.clock),
|
||||
activeIcon: FaIcon(FontAwesomeIcons.solidClock),
|
||||
label: 'Tracking',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
|
|
@ -47,8 +48,12 @@ class _MainScreenState extends State<MainScreen> {
|
|||
label: 'Tags',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(context.platformIcons.bookmarkOutline),
|
||||
activeIcon: Icon(context.platformIcons.bookmarkSolid),
|
||||
icon: FaIcon(
|
||||
FontAwesomeIcons.chartBar,
|
||||
), //Icon(context.platformIcons.bookmarkOutline),
|
||||
activeIcon: FaIcon(
|
||||
FontAwesomeIcons.solidChartBar,
|
||||
), //Icon(context.platformIcons.bookmarkSolid),
|
||||
label: 'Reports',
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ 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';
|
||||
|
|
@ -16,14 +17,12 @@ DateTime unixSecondsToDateTime(int ts) =>
|
|||
DateTime.fromMillisecondsSinceEpoch(ts * 1000, isUtc: true).toLocal();
|
||||
|
||||
extension TimeEntryFormatting on TimeEntry {
|
||||
/// Konvertiert den startTime (Unix Timestamp) in ein lokales DateTime Objekt.
|
||||
DateTime get startDateTime =>
|
||||
DateTime.fromMillisecondsSinceEpoch(
|
||||
startTime.toInt() * 1000,
|
||||
isUtc: true,
|
||||
).toLocal();
|
||||
|
||||
/// Konvertiert den optionalen endTime (Unix Timestamp) in ein lokales DateTime Objekt.
|
||||
DateTime? get endDateTime =>
|
||||
endTime == null
|
||||
? null
|
||||
|
|
@ -32,7 +31,6 @@ extension TimeEntryFormatting on TimeEntry {
|
|||
isUtc: true,
|
||||
).toLocal();
|
||||
|
||||
/// Formatiert die Dauer (durationSecs) als HH:MM:SS String.
|
||||
String get durationFormatted {
|
||||
final durationInSeconds = durationSecs?.toInt();
|
||||
if (durationInSeconds == null || durationInSeconds < 0) return '--:--:--';
|
||||
|
|
@ -47,7 +45,6 @@ extension TimeEntryFormatting on TimeEntry {
|
|||
}
|
||||
|
||||
extension ReportDataFormatting on ReportData {
|
||||
/// Formatiert die Gesamtdauer als HH:MM:SS String.
|
||||
String get totalDurationFormatted {
|
||||
final durationInSeconds = totalDurationSecs.toInt();
|
||||
if (durationInSeconds < 0) return '--:--:--';
|
||||
|
|
@ -61,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});
|
||||
|
|
@ -73,6 +70,8 @@ class ReportScreen extends StatefulWidget {
|
|||
class _ReportScreenState extends State<ReportScreen> {
|
||||
final DateTime _selectedDate = DateTime.now();
|
||||
ReportPeriod _selectedPeriod = ReportPeriod.day;
|
||||
DateTime _customStartDate = DateTime.now();
|
||||
DateTime _customEndDate = DateTime.now();
|
||||
Tag? _selectedTag;
|
||||
ReportData? _reportData;
|
||||
bool _isLoadingReport = false;
|
||||
|
|
@ -90,6 +89,9 @@ class _ReportScreenState extends State<ReportScreen> {
|
|||
@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();
|
||||
}
|
||||
|
||||
|
|
@ -99,8 +101,9 @@ class _ReportScreenState extends State<ReportScreen> {
|
|||
final Map<DateTime, Map<String, Duration>> dailyTagDurations = {};
|
||||
|
||||
for (final entry in entries) {
|
||||
if (entry.durationSecs == null || entry.durationSecs!.toInt() <= 0)
|
||||
if (entry.durationSecs == null || entry.durationSecs!.toInt() <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final entryDay = DateTime(
|
||||
entry.startDateTime.year,
|
||||
|
|
@ -334,7 +337,7 @@ class _ReportScreenState extends State<ReportScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
(DateTime, DateTime) _calculateDateRange() {
|
||||
(DateTime, DateTime) _calculateDateRange(ReportPeriod period) {
|
||||
final nowLocal = DateTime.now().toLocal();
|
||||
final startOfDay = DateTime(nowLocal.year, nowLocal.month, nowLocal.day);
|
||||
|
||||
|
|
@ -356,6 +359,13 @@ class _ReportScreenState extends State<ReportScreen> {
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -367,12 +377,33 @@ class _ReportScreenState extends State<ReportScreen> {
|
|||
});
|
||||
|
||||
final timeService = context.read<TimeTrackingService>();
|
||||
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(() {
|
||||
|
|
@ -382,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
|
||||
Widget build(BuildContext context) {
|
||||
final tags = context.watch<TimeTrackingService>().tags;
|
||||
|
|
@ -393,14 +447,24 @@ class _ReportScreenState extends State<ReportScreen> {
|
|||
children: [
|
||||
_buildFilterControls(tagOptions),
|
||||
Expanded(
|
||||
child:
|
||||
_isLoadingReport
|
||||
? Center(child: PlatformCircularProgressIndicator())
|
||||
: _reportData == null
|
||||
? const Center(
|
||||
child: Text('Keine Reportdaten gefunden oder Fehler.'),
|
||||
)
|
||||
: _buildReportView(),
|
||||
child: RefreshIndicator.adaptive(
|
||||
onRefresh: _generateReport,
|
||||
child:
|
||||
_isLoadingReport
|
||||
? Center(child: PlatformCircularProgressIndicator())
|
||||
: _reportData == null
|
||||
? ListView(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: MediaQuery.of(context).size.height * 0.5,
|
||||
child: Text(
|
||||
'Keine Reportdaten gefunden oder Fehler.',
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: _buildReportView(_reportData!),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -409,33 +473,71 @@ class _ReportScreenState extends State<ReportScreen> {
|
|||
|
||||
Widget _buildFilterControls(List<Tag?> 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<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(),
|
||||
PlatformWidget(
|
||||
material:
|
||||
(_, __) => DropdownButton<ReportPeriod>(
|
||||
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<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?>(
|
||||
|
|
@ -456,23 +558,135 @@ class _ReportScreenState extends State<ReportScreen> {
|
|||
}).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<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) {
|
||||
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: [
|
||||
|
|
@ -487,8 +701,6 @@ class _ReportScreenState extends State<ReportScreen> {
|
|||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 250, child: _buildPieChart(_reportData!)),
|
||||
const Divider(),
|
||||
SizedBox(
|
||||
height: 250,
|
||||
child: _buildBarChart(_reportData!, startDate, endDate),
|
||||
|
|
@ -507,108 +719,11 @@ class _ReportScreenState extends State<ReportScreen> {
|
|||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 300, child: _buildReportList(_reportData!.entries)),
|
||||
SizedBox(height: 600, child: _buildReportList(_reportData!.entries)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPieChart(ReportData data) {
|
||||
Map<String, double> durationPerTag = {};
|
||||
for (var entry in data.entries) {
|
||||
final tagName = entry.tagName ?? 'Ohne Tag';
|
||||
final duration = (entry.durationSecs?.toInt() ?? 0).toDouble();
|
||||
durationPerTag[tagName] = (durationPerTag[tagName] ?? 0) + duration;
|
||||
}
|
||||
|
||||
if (durationPerTag.isEmpty) {
|
||||
return const Center(child: Text("Keine Daten für Chart"));
|
||||
}
|
||||
|
||||
List<Color> colors = Colors.primaries.take(durationPerTag.length).toList();
|
||||
if (durationPerTag.length > Colors.primaries.length) {
|
||||
colors.addAll(
|
||||
Colors.accents.take(durationPerTag.length - Colors.primaries.length),
|
||||
);
|
||||
}
|
||||
|
||||
int colorIndex = 0;
|
||||
List<PieChartSectionData> sections =
|
||||
durationPerTag.entries.map((entry) {
|
||||
final isTouched = false;
|
||||
final fontSize = isTouched ? 18.0 : 14.0;
|
||||
final radius = isTouched ? 60.0 : 50.0;
|
||||
final color = colors[colorIndex % colors.length];
|
||||
colorIndex++;
|
||||
|
||||
final hours = (entry.value / 3600).toStringAsFixed(1);
|
||||
|
||||
return PieChartSectionData(
|
||||
color: color,
|
||||
value: entry.value,
|
||||
title: '${entry.key}\n${hours}h',
|
||||
radius: radius,
|
||||
titleStyle: TextStyle(
|
||||
fontSize: fontSize,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
shadows: [
|
||||
Shadow(color: Colors.black.withOpacity(0.7), blurRadius: 2),
|
||||
],
|
||||
),
|
||||
titlePositionPercentageOffset: 0.6,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: PieChart(
|
||||
PieChartData(
|
||||
sections: sections,
|
||||
centerSpaceRadius: 40,
|
||||
sectionsSpace: 2,
|
||||
),
|
||||
duration: const Duration(milliseconds: 150),
|
||||
curve: Curves.linear,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Widget _buildDataTable(List<TimeEntry> entries) {
|
||||
// final DateFormat timeFormatter = DateFormat('HH:mm:ss');
|
||||
// final DateFormat dateFormatter = DateFormat('dd.MM.yy');
|
||||
|
||||
// return DataTable(
|
||||
// columnSpacing: 10,
|
||||
// columns: [
|
||||
// DataColumn(label: PlatformText('Tag')),
|
||||
// DataColumn(label: PlatformText('Start')),
|
||||
// DataColumn(label: PlatformText('Ende')),
|
||||
// DataColumn(label: PlatformText('Dauer'), numeric: true),
|
||||
// ],
|
||||
// rows:
|
||||
// entries.map((entry) {
|
||||
// return DataRow(
|
||||
// cells: [
|
||||
// DataCell(PlatformText(entry.tagName ?? '-')),
|
||||
// DataCell(
|
||||
// PlatformText(
|
||||
// '${dateFormatter.format(unixSecondsToDateTime(entry.startTime))}\n${timeFormatter.format(unixSecondsToDateTime(entry.startTime))}',
|
||||
// ),
|
||||
// ),
|
||||
// DataCell(
|
||||
// entry.endTime != null
|
||||
// ? PlatformText(
|
||||
// '${dateFormatter.format(unixSecondsToDateTime(entry.endTime!))}\n${timeFormatter.format(unixSecondsToDateTime(entry.endTime!))}',
|
||||
// )
|
||||
// : PlatformText('-'),
|
||||
// ),
|
||||
// DataCell(Text(entry.durationFormatted)),
|
||||
// ],
|
||||
// );
|
||||
// }).toList(),
|
||||
// );
|
||||
// }
|
||||
|
||||
void _confirmAndDeleteEntry(TimeEntry entry, BuildContext context) {
|
||||
showPlatformDialog(
|
||||
context: context,
|
||||
|
|
@ -649,8 +764,8 @@ class _ReportScreenState extends State<ReportScreen> {
|
|||
|
||||
Widget _buildReportList(List<TimeEntry> entries) {
|
||||
return ListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
// shrinkWrap: true,
|
||||
// physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: entries.length,
|
||||
itemBuilder: (context, index) {
|
||||
final entry = entries[index];
|
||||
|
|
@ -855,10 +970,7 @@ class _ReportScreenState extends State<ReportScreen> {
|
|||
actions: <Widget>[
|
||||
PlatformDialogAction(
|
||||
child: PlatformText('OK'),
|
||||
onPressed:
|
||||
() => Navigator.pop(
|
||||
dialogContext,
|
||||
), // Pop using dialog's context
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
|
||||
import 'package:flutter/cupertino.dart' show CupertinoIcons;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:timetracker/src/rust/api.dart';
|
||||
import 'package:timetracker/time_tracking_service.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:flutter_slidable/flutter_slidable.dart';
|
||||
|
||||
class TagsScreen extends StatefulWidget {
|
||||
const TagsScreen({super.key});
|
||||
|
|
@ -40,6 +42,129 @@ class _TagsScreenState extends State<TagsScreen> {
|
|||
}
|
||||
}
|
||||
|
||||
void _showEditTagDialog(BuildContext context, Tag tag) {
|
||||
final TextEditingController editController = TextEditingController(
|
||||
text: tag.name,
|
||||
);
|
||||
|
||||
showPlatformDialog(
|
||||
context: context,
|
||||
builder:
|
||||
(dialogContext) => PlatformAlertDialog(
|
||||
title: PlatformText('Tag bearbeiten'),
|
||||
content: PlatformTextField(
|
||||
controller: editController,
|
||||
autofocus: true,
|
||||
hintText: 'Neuer Tag-Name',
|
||||
material:
|
||||
(_, __) => MaterialTextFieldData(
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Neuer Tag-Name',
|
||||
),
|
||||
),
|
||||
cupertino:
|
||||
(_, __) =>
|
||||
CupertinoTextFieldData(placeholder: 'Neuer Tag-Name'),
|
||||
),
|
||||
actions: [
|
||||
PlatformDialogAction(
|
||||
child: PlatformText('Abbrechen'),
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
),
|
||||
PlatformDialogAction(
|
||||
child: PlatformText('Speichern'),
|
||||
onPressed: () async {
|
||||
final newName = editController.text.trim();
|
||||
if (newName.isEmpty) {
|
||||
_showPlatformFeedbackDialog(
|
||||
context,
|
||||
'Fehler',
|
||||
'Tag-Name darf nicht leer sein.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (newName == tag.name) {
|
||||
Navigator.pop(dialogContext);
|
||||
return;
|
||||
}
|
||||
|
||||
final success = await context
|
||||
.read<TimeTrackingService>()
|
||||
.flUpdateTag(tag.id.toInt(), newName);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
Navigator.pop(dialogContext);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
if (success) {
|
||||
_showPlatformFeedbackDialog(
|
||||
context,
|
||||
'Erfolg',
|
||||
'Tag aktualisiert.',
|
||||
);
|
||||
} else {
|
||||
_showPlatformFeedbackDialog(
|
||||
context,
|
||||
'Fehler',
|
||||
'Tag konnte nicht aktualisiert werden (Name evtl. vergeben?).',
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _confirmAndDeleteTag(BuildContext context, Tag tag) {
|
||||
showPlatformDialog(
|
||||
context: context,
|
||||
builder:
|
||||
(dialogContext) => PlatformAlertDialog(
|
||||
title: PlatformText('Tag löschen?'),
|
||||
content: PlatformText(
|
||||
'Möchtest du den Tag "${tag.name}" wirklich löschen? Zugeordnete Zeiteinträge verlieren ihre Tag-Zuweisung.',
|
||||
),
|
||||
actions: <Widget>[
|
||||
PlatformDialogAction(
|
||||
child: PlatformText('Abbrechen'),
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
),
|
||||
PlatformDialogAction(
|
||||
child: PlatformText('Löschen'),
|
||||
cupertino:
|
||||
(_, __) =>
|
||||
CupertinoDialogActionData(isDestructiveAction: true),
|
||||
onPressed: () async {
|
||||
Navigator.pop(dialogContext);
|
||||
|
||||
final success = await context
|
||||
.read<TimeTrackingService>()
|
||||
.flDeleteTag(tag.id.toInt());
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
if (success) {
|
||||
} else {
|
||||
_showPlatformFeedbackDialog(
|
||||
context,
|
||||
'Fehler',
|
||||
'Tag konnte nicht gelöscht werden.',
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tags = context.watch<TimeTrackingService>().tags;
|
||||
|
|
@ -51,6 +176,7 @@ class _TagsScreenState extends State<TagsScreen> {
|
|||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: PlatformTextField(
|
||||
|
|
@ -85,9 +211,36 @@ class _TagsScreenState extends State<TagsScreen> {
|
|||
itemCount: tags.length,
|
||||
itemBuilder: (context, index) {
|
||||
final tag = tags[index];
|
||||
return ListTile(
|
||||
title: PlatformText(tag.name),
|
||||
trailing: PlatformText('ID: ${tag.id}'),
|
||||
return Slidable(
|
||||
key: ValueKey(tag.id.toInt()),
|
||||
endActionPane: ActionPane(
|
||||
motion: const StretchMotion(),
|
||||
children: [
|
||||
SlidableAction(
|
||||
onPressed: (context) {
|
||||
_showEditTagDialog(this.context, tag);
|
||||
},
|
||||
backgroundColor: Colors.blue,
|
||||
foregroundColor: Colors.white,
|
||||
icon: PlatformIcons(context).edit,
|
||||
label: 'Bearbeiten',
|
||||
),
|
||||
SlidableAction(
|
||||
onPressed: (context) {
|
||||
_confirmAndDeleteTag(this.context, tag);
|
||||
},
|
||||
backgroundColor: Colors.red,
|
||||
foregroundColor: Colors.white,
|
||||
icon: PlatformIcons(context).delete,
|
||||
label: 'Löschen',
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
child: ListTile(
|
||||
title: PlatformText(tag.name),
|
||||
trailing: PlatformText('ID: ${tag.id.toInt()}'),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
|
|||
|
|
@ -14,6 +14,12 @@ Future<void> initApp({required String dbDirectoryPath}) =>
|
|||
Future<PlatformInt64> createTag({required String name}) =>
|
||||
RustLib.instance.api.crateApiCreateTag(name: name);
|
||||
|
||||
Future<void> updateTag({required PlatformInt64 id, required String newName}) =>
|
||||
RustLib.instance.api.crateApiUpdateTag(id: id, newName: newName);
|
||||
|
||||
Future<void> deleteTag({required PlatformInt64 id}) =>
|
||||
RustLib.instance.api.crateApiDeleteTag(id: id);
|
||||
|
||||
Future<List<Tag>> getTags() => RustLib.instance.api.crateApiGetTags();
|
||||
|
||||
Future<PlatformInt64> startTracking({
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ class RustLib extends BaseEntrypoint<RustLibApi, RustLibApiImpl, RustLibWire> {
|
|||
String get codegenVersion => '2.9.0';
|
||||
|
||||
@override
|
||||
int get rustContentHash => -79634774;
|
||||
int get rustContentHash => 350676645;
|
||||
|
||||
static const kDefaultExternalLibraryLoaderConfig =
|
||||
ExternalLibraryLoaderConfig(
|
||||
|
|
@ -75,6 +75,8 @@ class RustLib extends BaseEntrypoint<RustLibApi, RustLibApiImpl, RustLibWire> {
|
|||
abstract class RustLibApi extends BaseApi {
|
||||
Future<PlatformInt64> crateApiCreateTag({required String name});
|
||||
|
||||
Future<void> crateApiDeleteTag({required PlatformInt64 id});
|
||||
|
||||
Future<void> crateApiDeleteTimeEntry({required PlatformInt64 id});
|
||||
|
||||
Future<ReportData> crateApiGenerateReport({
|
||||
|
|
@ -99,6 +101,11 @@ abstract class RustLibApi extends BaseApi {
|
|||
required PlatformInt64 endTimeUnixTs,
|
||||
});
|
||||
|
||||
Future<void> crateApiUpdateTag({
|
||||
required PlatformInt64 id,
|
||||
required String newName,
|
||||
});
|
||||
|
||||
Future<void> crateApiUpdateTimeEntry({
|
||||
required PlatformInt64 entryId,
|
||||
PlatformInt64? newTagId,
|
||||
|
|
@ -144,7 +151,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||
const TaskConstMeta(debugName: "create_tag", argNames: ["name"]);
|
||||
|
||||
@override
|
||||
Future<void> crateApiDeleteTimeEntry({required PlatformInt64 id}) {
|
||||
Future<void> crateApiDeleteTag({required PlatformInt64 id}) {
|
||||
return handler.executeNormal(
|
||||
NormalTask(
|
||||
callFfi: (port_) {
|
||||
|
|
@ -161,6 +168,34 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||
decodeSuccessData: sse_decode_unit,
|
||||
decodeErrorData: sse_decode_AnyhowException,
|
||||
),
|
||||
constMeta: kCrateApiDeleteTagConstMeta,
|
||||
argValues: [id],
|
||||
apiImpl: this,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
TaskConstMeta get kCrateApiDeleteTagConstMeta =>
|
||||
const TaskConstMeta(debugName: "delete_tag", argNames: ["id"]);
|
||||
|
||||
@override
|
||||
Future<void> crateApiDeleteTimeEntry({required PlatformInt64 id}) {
|
||||
return handler.executeNormal(
|
||||
NormalTask(
|
||||
callFfi: (port_) {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
sse_encode_i_64(id, serializer);
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 3,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_unit,
|
||||
decodeErrorData: sse_decode_AnyhowException,
|
||||
),
|
||||
constMeta: kCrateApiDeleteTimeEntryConstMeta,
|
||||
argValues: [id],
|
||||
apiImpl: this,
|
||||
|
|
@ -187,7 +222,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 3,
|
||||
funcId: 4,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
|
|
@ -216,7 +251,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 4,
|
||||
funcId: 5,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
|
|
@ -246,7 +281,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 5,
|
||||
funcId: 6,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
|
|
@ -274,7 +309,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 6,
|
||||
funcId: 7,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
|
|
@ -306,7 +341,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 7,
|
||||
funcId: 8,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
|
|
@ -340,7 +375,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 8,
|
||||
funcId: 9,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
|
|
@ -360,6 +395,38 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||
argNames: ["entryId", "endTimeUnixTs"],
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> crateApiUpdateTag({
|
||||
required PlatformInt64 id,
|
||||
required String newName,
|
||||
}) {
|
||||
return handler.executeNormal(
|
||||
NormalTask(
|
||||
callFfi: (port_) {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
sse_encode_i_64(id, serializer);
|
||||
sse_encode_String(newName, serializer);
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 10,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_unit,
|
||||
decodeErrorData: sse_decode_AnyhowException,
|
||||
),
|
||||
constMeta: kCrateApiUpdateTagConstMeta,
|
||||
argValues: [id, newName],
|
||||
apiImpl: this,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
TaskConstMeta get kCrateApiUpdateTagConstMeta =>
|
||||
const TaskConstMeta(debugName: "update_tag", argNames: ["id", "newName"]);
|
||||
|
||||
@override
|
||||
Future<void> crateApiUpdateTimeEntry({
|
||||
required PlatformInt64 entryId,
|
||||
|
|
@ -378,7 +445,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 9,
|
||||
funcId: 11,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -105,16 +105,13 @@ class TimeTrackingService extends ChangeNotifier {
|
|||
|
||||
Future<bool> flCreateTag(String name) async {
|
||||
if (name.trim().isEmpty) {
|
||||
log("Cannot create tag: Name is empty.");
|
||||
return false;
|
||||
}
|
||||
final trimmedName = name.trim();
|
||||
|
||||
try {
|
||||
final tagId = await createTag(name: trimmedName);
|
||||
log("Tag created with ID: $tagId");
|
||||
_tags = await getTags();
|
||||
notifyListeners();
|
||||
await _loadUpdatedTags();
|
||||
return true;
|
||||
} on FrbException catch (e) {
|
||||
log("Error creating tag: $e");
|
||||
|
|
@ -124,6 +121,27 @@ class TimeTrackingService extends ChangeNotifier {
|
|||
return false;
|
||||
}
|
||||
}
|
||||
// Future<bool> flCreateTag(String name) async {
|
||||
// if (name.trim().isEmpty) {
|
||||
// log("Cannot create tag: Name is empty.");
|
||||
// return false;
|
||||
// }
|
||||
// final trimmedName = name.trim();
|
||||
|
||||
// try {
|
||||
// final tagId = await createTag(name: trimmedName);
|
||||
// log("Tag created with ID: $tagId");
|
||||
// _tags = await getTags();
|
||||
// notifyListeners();
|
||||
// return true;
|
||||
// } on FrbException catch (e) {
|
||||
// log("Error creating tag: $e");
|
||||
// return false;
|
||||
// } catch (e) {
|
||||
// log("Unexpected error creating tag: $e");
|
||||
// return false;
|
||||
// }
|
||||
// }
|
||||
|
||||
Future<ReportData?> flGetReport(
|
||||
int? tagId,
|
||||
|
|
@ -184,4 +202,50 @@ class TimeTrackingService extends ChangeNotifier {
|
|||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> flUpdateTag(int tagId, String newName) async {
|
||||
if (newName.trim().isEmpty) {
|
||||
log('Service: Cannot update tag $tagId: New name is empty.');
|
||||
return false;
|
||||
}
|
||||
final trimmedName = newName.trim();
|
||||
log('Service: Attempting to update tag $tagId to "$trimmedName"');
|
||||
try {
|
||||
await updateTag(id: tagId.toInt(), newName: trimmedName);
|
||||
log('Service: Successfully updated tag $tagId');
|
||||
await _loadUpdatedTags();
|
||||
return true; // Erfolg
|
||||
} on FrbException catch (e, s) {
|
||||
log('Service: Error updating tag $tagId: $e\n$s');
|
||||
return false;
|
||||
} catch (e, s) {
|
||||
log('Service: Unexpected error updating tag $tagId: $e\n$s');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> flDeleteTag(int tagId) async {
|
||||
log('Service: Attempting to delete tag $tagId');
|
||||
try {
|
||||
await deleteTag(id: tagId.toInt());
|
||||
log('Service: Successfully deleted tag $tagId');
|
||||
await _loadUpdatedTags();
|
||||
return true;
|
||||
} on FrbException catch (e, s) {
|
||||
log('Service: Error deleting tag $tagId: $e\n$s');
|
||||
return false;
|
||||
} catch (e, s) {
|
||||
log('Service: Unexpected error deleting tag $tagId: $e\n$s');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadUpdatedTags() async {
|
||||
try {
|
||||
_tags = await getTags();
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
log("Error refreshing tags after CUD operation: $e");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
33
pubspec.lock
33
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:
|
||||
|
|
@ -152,6 +157,14 @@ packages:
|
|||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
font_awesome_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: font_awesome_flutter
|
||||
sha256: d3a89184101baec7f4600d58840a764d2ef760fe1c5a20ef9e6b0e9b24a07a3a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.8.0"
|
||||
fuchsia_remote_debug_protocol:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
|
|
@ -174,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:
|
||||
|
|
@ -402,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:
|
||||
|
|
@ -418,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:
|
||||
|
|
|
|||
|
|
@ -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,10 +20,12 @@ 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
|
||||
flutter_slidable: ^4.0.0
|
||||
font_awesome_flutter: ^10.8.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
|
@ -32,6 +36,4 @@ dev_dependencies:
|
|||
sdk: flutter
|
||||
|
||||
flutter:
|
||||
|
||||
uses-material-design: true
|
||||
|
||||
|
|
|
|||
3
renovate.json
Normal file
3
renovate.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
|
||||
}
|
||||
|
|
@ -45,6 +45,20 @@ pub fn create_tag(name: String) -> Result<i64> {
|
|||
database::create_tag_internal(name.trim())
|
||||
}
|
||||
|
||||
pub fn update_tag(id: i64, new_name: String) -> Result<()> {
|
||||
log::debug!(
|
||||
"API: update_tag called for id {} with name '{}'",
|
||||
id,
|
||||
new_name
|
||||
);
|
||||
database::update_tag_internal(id, &new_name)
|
||||
}
|
||||
|
||||
pub fn delete_tag(id: i64) -> Result<()> {
|
||||
log::debug!("API: delete_tag called for id {}", id);
|
||||
database::delete_tag_internal(id)
|
||||
}
|
||||
|
||||
pub fn get_tags() -> Result<Vec<Tag>> {
|
||||
log::debug!("API: get_tags called");
|
||||
database::get_tags_internal()
|
||||
|
|
|
|||
|
|
@ -106,6 +106,44 @@ pub(super) fn get_tags_internal() -> Result<Vec<TagInternal>> {
|
|||
})
|
||||
}
|
||||
|
||||
pub(super) fn update_tag_internal(id: i64, new_name: &str) -> Result<()> {
|
||||
log::debug!(
|
||||
"RUST_DB: Attempting to update tag_id {} to name '{}'",
|
||||
id,
|
||||
new_name
|
||||
);
|
||||
ensure!(!new_name.trim().is_empty(), "New tag name cannot be empty");
|
||||
|
||||
let name = new_name.trim();
|
||||
|
||||
with_db_connection(|conn| {
|
||||
let rows_affected = conn
|
||||
.execute("UPDATE tags SET name = ?1 WHERE id = ?2", params![name, id])
|
||||
.context("Failed to execute update tag SQL")?;
|
||||
|
||||
ensure!(rows_affected > 0, "Tag {} not found for update", id);
|
||||
|
||||
log::info!("RUST_DB: Successfully updated tag {}", id);
|
||||
Ok(())
|
||||
})
|
||||
.with_context(|| format!("Failed operation for updating tag_id {}", id))
|
||||
}
|
||||
|
||||
pub(super) fn delete_tag_internal(id: i64) -> Result<()> {
|
||||
log::debug!("RUST_DB: Attempting to delete tag_id {}", id);
|
||||
with_db_connection(|conn| {
|
||||
let rows_affected = conn
|
||||
.execute("DELETE FROM tags WHERE id = ?1", params![id])
|
||||
.context("Failed to execute delete tag SQL")?;
|
||||
|
||||
ensure!(rows_affected > 0, "Tag {} not found for deletion", id);
|
||||
|
||||
log::info!("RUST_DB: Successfully deleted tag {}", id);
|
||||
Ok(())
|
||||
})
|
||||
.with_context(|| format!("Failed operation for deleting tag_id {}", id))
|
||||
}
|
||||
|
||||
pub(super) fn start_tracking_internal(tag_id: Option<i64>, start_time_unix_ts: i64) -> Result<i64> {
|
||||
with_db_connection(|conn| {
|
||||
conn.execute(
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ flutter_rust_bridge::frb_generated_boilerplate!(
|
|||
default_rust_auto_opaque = RustAutoOpaqueMoi,
|
||||
);
|
||||
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.9.0";
|
||||
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -79634774;
|
||||
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 350676645;
|
||||
|
||||
// Section: executor
|
||||
|
||||
|
|
@ -80,6 +80,41 @@ fn wire__crate__api__create_tag_impl(
|
|||
},
|
||||
)
|
||||
}
|
||||
fn wire__crate__api__delete_tag_impl(
|
||||
port_: flutter_rust_bridge::for_generated::MessagePort,
|
||||
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
||||
rust_vec_len_: i32,
|
||||
data_len_: i32,
|
||||
) {
|
||||
FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::<flutter_rust_bridge::for_generated::SseCodec, _, _>(
|
||||
flutter_rust_bridge::for_generated::TaskInfo {
|
||||
debug_name: "delete_tag",
|
||||
port: Some(port_),
|
||||
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
|
||||
},
|
||||
move || {
|
||||
let message = unsafe {
|
||||
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
|
||||
ptr_,
|
||||
rust_vec_len_,
|
||||
data_len_,
|
||||
)
|
||||
};
|
||||
let mut deserializer =
|
||||
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
|
||||
let api_id = <i64>::sse_decode(&mut deserializer);
|
||||
deserializer.end();
|
||||
move |context| {
|
||||
transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>(
|
||||
(move || {
|
||||
let output_ok = crate::api::delete_tag(api_id)?;
|
||||
Ok(output_ok)
|
||||
})(),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
fn wire__crate__api__delete_time_entry_impl(
|
||||
port_: flutter_rust_bridge::for_generated::MessagePort,
|
||||
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
||||
|
|
@ -333,6 +368,42 @@ fn wire__crate__api__stop_tracking_impl(
|
|||
},
|
||||
)
|
||||
}
|
||||
fn wire__crate__api__update_tag_impl(
|
||||
port_: flutter_rust_bridge::for_generated::MessagePort,
|
||||
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
||||
rust_vec_len_: i32,
|
||||
data_len_: i32,
|
||||
) {
|
||||
FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::<flutter_rust_bridge::for_generated::SseCodec, _, _>(
|
||||
flutter_rust_bridge::for_generated::TaskInfo {
|
||||
debug_name: "update_tag",
|
||||
port: Some(port_),
|
||||
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
|
||||
},
|
||||
move || {
|
||||
let message = unsafe {
|
||||
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
|
||||
ptr_,
|
||||
rust_vec_len_,
|
||||
data_len_,
|
||||
)
|
||||
};
|
||||
let mut deserializer =
|
||||
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
|
||||
let api_id = <i64>::sse_decode(&mut deserializer);
|
||||
let api_new_name = <String>::sse_decode(&mut deserializer);
|
||||
deserializer.end();
|
||||
move |context| {
|
||||
transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>(
|
||||
(move || {
|
||||
let output_ok = crate::api::update_tag(api_id, api_new_name)?;
|
||||
Ok(output_ok)
|
||||
})(),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
fn wire__crate__api__update_time_entry_impl(
|
||||
port_: flutter_rust_bridge::for_generated::MessagePort,
|
||||
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
||||
|
|
@ -551,14 +622,16 @@ fn pde_ffi_dispatcher_primary_impl(
|
|||
// Codec=Pde (Serialization + dispatch), see doc to use other codecs
|
||||
match func_id {
|
||||
1 => wire__crate__api__create_tag_impl(port, ptr, rust_vec_len, data_len),
|
||||
2 => wire__crate__api__delete_time_entry_impl(port, ptr, rust_vec_len, data_len),
|
||||
3 => wire__crate__api__generate_report_impl(port, ptr, rust_vec_len, data_len),
|
||||
4 => wire__crate__api__get_last_unfinished_tracking_impl(port, ptr, rust_vec_len, data_len),
|
||||
5 => wire__crate__api__get_tags_impl(port, ptr, rust_vec_len, data_len),
|
||||
6 => wire__crate__api__init_app_impl(port, ptr, rust_vec_len, data_len),
|
||||
7 => wire__crate__api__start_tracking_impl(port, ptr, rust_vec_len, data_len),
|
||||
8 => wire__crate__api__stop_tracking_impl(port, ptr, rust_vec_len, data_len),
|
||||
9 => wire__crate__api__update_time_entry_impl(port, ptr, rust_vec_len, data_len),
|
||||
2 => wire__crate__api__delete_tag_impl(port, ptr, rust_vec_len, data_len),
|
||||
3 => wire__crate__api__delete_time_entry_impl(port, ptr, rust_vec_len, data_len),
|
||||
4 => wire__crate__api__generate_report_impl(port, ptr, rust_vec_len, data_len),
|
||||
5 => wire__crate__api__get_last_unfinished_tracking_impl(port, ptr, rust_vec_len, data_len),
|
||||
6 => wire__crate__api__get_tags_impl(port, ptr, rust_vec_len, data_len),
|
||||
7 => wire__crate__api__init_app_impl(port, ptr, rust_vec_len, data_len),
|
||||
8 => wire__crate__api__start_tracking_impl(port, ptr, rust_vec_len, data_len),
|
||||
9 => wire__crate__api__stop_tracking_impl(port, ptr, rust_vec_len, data_len),
|
||||
10 => wire__crate__api__update_tag_impl(port, ptr, rust_vec_len, data_len),
|
||||
11 => wire__crate__api__update_time_entry_impl(port, ptr, rust_vec_len, data_len),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue