40 Commits
Author SHA1 Message Date
marco 8113ce9c22 create toAlarm and fromAlarm 2026-07-20 13:52:11 +02:00
marco 06de421ee2 removed taskId 2026-07-20 13:43:14 +02:00
marco e7d6735d27 removed taskid from alarm 2026-07-20 13:41:55 +02:00
marco 79cae9570d added alarms to task model 2026-07-20 13:39:26 +02:00
marco 7fed7bfe16 added form widget for fixed time alarm 2026-07-20 13:35:22 +02:00
marco 02469faf52 refactored project structure 2026-07-17 17:08:31 +02:00
marco 3ceb0896a4 created alram form widget placeholders 2026-07-17 16:58:05 +02:00
marco c7ff39e55c refactored alarm models 2026-07-17 16:57:55 +02:00
marco a33c4bbb12 changed theme 2026-07-16 16:45:18 +02:00
marco 5fc290dbcb added alarm type 2026-07-16 16:45:01 +02:00
marco 463a2c8fda added alarmtype enum 2026-07-16 16:44:45 +02:00
marco dcae40497d removed required fields 2026-07-16 16:15:33 +02:00
marco a440c29043 made fields nullable 2026-07-16 16:13:25 +02:00
marco e1eafdca59 added validator for whole numbers 2026-07-16 16:10:33 +02:00
marco 4af3683b73 changed dropdownmenu and button theme 2026-07-16 16:10:22 +02:00
marco 505aff4232 added create models for alarms 2026-07-16 16:10:02 +02:00
marco 21ef9c4ab5 added location overview screen 2026-06-19 14:03:05 +02:00
marco 9d0cb7668d added updateLocation function 2026-06-19 14:02:35 +02:00
marco 383de6a33b added from string constructor 2026-06-19 14:02:11 +02:00
marco 9e950a1767 added validators 2026-06-19 14:01:58 +02:00
marco 20a7c88d2f fixed warning 2026-06-19 13:48:55 +02:00
marco e8057a8cc6 added getter for locations 2026-06-19 13:21:23 +02:00
marco a71cc454eb extended location and latlng models 2026-06-19 13:21:08 +02:00
marco f1c1578620 added const for formColumnSpacing 2026-06-19 13:20:46 +02:00
marco 5ef22c7b50 added border radius to background 2026-06-19 01:43:14 +02:00
marco 6dc7161b41 added title 2026-06-19 01:36:22 +02:00
marco f1756b30d1 added padding to views 2026-06-19 01:35:46 +02:00
marco 77a524f3ec added basic app theme 2026-06-19 01:25:56 +02:00
marco 064c014f8b removed unused import 2026-06-19 00:35:38 +02:00
marco 410a7eb843 made tasks dismissible 2026-06-19 00:35:20 +02:00
marco 20b017b066 added alarmkey and locationkey to allowlist 2026-06-19 00:21:34 +02:00
marco a3258b84fe added controllers to context 2026-06-19 00:02:35 +02:00
marco f088b84c54 updated imports 2026-06-18 23:20:38 +02:00
marco 5a39148577 moved controllers to folder 2026-06-18 23:19:44 +02:00
marco 2b718b5bd8 added controller for locations 2026-06-18 23:18:09 +02:00
marco 023610804d added location handling to storage 2026-06-18 23:16:20 +02:00
marco cee5af0f84 removed alarms and location from task model 2026-06-18 23:02:20 +02:00
marco 999023e48a created controller for alarms 2026-06-18 23:00:45 +02:00
marco 82a74a66b5 added id to alarms 2026-06-18 22:59:47 +02:00
marco e645081204 changed alarm model id to taskId 2026-06-18 22:48:32 +02:00
34 changed files with 1101 additions and 160 deletions
+59
View File
@@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
class AppTheme {
AppTheme._();
static const double formColumnSpacing = 12.0;
static ThemeData get lightTheme => _baseTheme(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.indigo,
brightness: Brightness.light,
),
);
static ThemeData get darkTheme => _baseTheme(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.indigo,
brightness: Brightness.dark,
),
);
static ThemeData _baseTheme({required ColorScheme colorScheme}) {
final theme = ThemeData(useMaterial3: true, colorScheme: colorScheme);
final universalBorderRadius = BorderRadius.circular(12);
return theme.copyWith(
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(borderRadius: universalBorderRadius),
),
listTileTheme: ListTileThemeData(
shape: RoundedRectangleBorder(
borderRadius: universalBorderRadius,
side: BorderSide(color: colorScheme.secondaryContainer, width: 2),
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ButtonStyle(
minimumSize: WidgetStatePropertyAll(Size(0, 56)),
shape: WidgetStateOutlinedBorder.resolveWith(
(states) =>
RoundedRectangleBorder(borderRadius: universalBorderRadius),
),
),
),
dropdownMenuTheme: DropdownMenuThemeData(
inputDecorationTheme: InputDecorationThemeData(
border: OutlineInputBorder(borderRadius: universalBorderRadius),
),
menuStyle: MenuStyle(
shape: WidgetStateOutlinedBorder.resolveWith(
(states) =>
RoundedRectangleBorder(borderRadius: universalBorderRadius),
),
),
),
);
}
}
+17 -4
View File
@@ -1,10 +1,14 @@
import 'package:flutter/material.dart';
import 'model/repositories/local_repository.dart';
import 'app_theme.dart';
import 'repositories/local_repository.dart';
import 'pages/locations_overview_page.dart';
import 'pages/task_edit_page.dart';
import 'pages/task_overview_page.dart';
import 'service/controller_scope.dart';
import 'service/task_controller.dart';
import 'service/controllers/alarm_controller.dart';
import 'service/controllers/location_controller.dart';
import 'service/controllers/task_controller.dart';
void main() async {
final repository = LocalRepository();
@@ -13,8 +17,14 @@ void main() async {
runApp(
ControllerScope(
controller: TaskController(repository),
child: const MainApp(),
controller: LocationController(repository),
child: ControllerScope(
controller: AlarmController(repository),
child: ControllerScope(
controller: TaskController(repository),
child: const MainApp(),
),
),
),
);
}
@@ -25,9 +35,12 @@ class MainApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme,
routes: {
TaskOverviewPage.routeName: (context) => TaskOverviewPage(),
TaskEditPage.routeName: (context) => TaskEditPage(),
LocationsOverviewPage.routeName: (context) => LocationsOverviewPage(),
},
initialRoute: TaskOverviewPage.routeName,
);
-29
View File
@@ -1,29 +0,0 @@
import 'location_alarm.dart';
import 'time_alarm.dart';
abstract class Alarm {
String get id;
Map<String, dynamic> toJson();
factory Alarm.fromJson(Map<String, dynamic> json) {
if (json.containsKey('triggerAt')) {
return TimeAlarm.fromJson(json);
}
if (json.containsKey('location')) {
return LocationAlarm.fromJson(json);
}
throw ArgumentError('Unknown alarm type');
}
@override
bool operator ==(Object other) {
if (other is! Alarm) return false;
return hashCode == other.hashCode;
}
@override
int get hashCode => id.hashCode;
}
+36
View File
@@ -0,0 +1,36 @@
import 'fixed_time_alarm.dart';
import 'location_alarm.dart';
import 'relative_time_alarm.dart';
abstract class Alarm {
String get id;
AlarmType get alarmType;
Map<String, dynamic> toJson();
factory Alarm.fromJson(Map<String, dynamic> json) {
if (json.containsKey('alarmType')) {
switch (json['alarmType'] as String) {
case 'timeFixed':
return FixedTimeAlarm.fromJson(json);
case 'timeRelative':
return RelativeTimeAlarm.fromJson(json);
case 'location':
return LocationAlarm.fromJson(json);
}
}
throw ArgumentError('Unknown alarm type');
}
@override
bool operator ==(Object other) {
if (other is! Alarm) return false;
return hashCode == other.hashCode;
}
@override
int get hashCode => id.hashCode;
}
enum AlarmType { timeFixed, timeRelative, location }
+30
View File
@@ -0,0 +1,30 @@
import 'alarm.dart';
class FixedTimeAlarm implements Alarm {
@override
final String id;
final DateTime triggerAt;
@override
final AlarmType alarmType;
const FixedTimeAlarm({required this.id, required this.triggerAt})
: alarmType = AlarmType.timeFixed;
factory FixedTimeAlarm.fromJson(Map<String, dynamic> json) {
return FixedTimeAlarm(
id: json['id'] as String,
triggerAt: DateTime.parse(json['triggerAt'] as String),
);
}
@override
Map<String, dynamic> toJson() {
return {
'id': id,
'triggerAt': triggerAt.toIso8601String(),
'alarmType': alarmType,
};
}
}
@@ -1,5 +1,5 @@
import 'alarm.dart';
import 'location.dart';
import '../location.dart';
class LocationAlarm implements Alarm {
@override
@@ -9,11 +9,14 @@ class LocationAlarm implements Alarm {
final int radiusMeters;
@override
final AlarmType alarmType;
const LocationAlarm({
required this.id,
required this.location,
required this.radiusMeters,
});
}) : alarmType = AlarmType.location;
factory LocationAlarm.fromJson(Map<String, dynamic> json) {
return LocationAlarm(
@@ -29,6 +32,7 @@ class LocationAlarm implements Alarm {
'id': id,
'location': location.toJson(),
'radiusMeters': radiusMeters,
'alarmType': alarmType,
};
}
}
+30
View File
@@ -0,0 +1,30 @@
import 'alarm.dart';
class RelativeTimeAlarm implements Alarm {
@override
final String id;
final DateTime triggerAt;
@override
final AlarmType alarmType;
const RelativeTimeAlarm({required this.id, required this.triggerAt})
: alarmType = AlarmType.timeRelative;
factory RelativeTimeAlarm.fromJson(Map<String, dynamic> json) {
return RelativeTimeAlarm(
id: json['id'] as String,
triggerAt: DateTime.parse(json['triggerAt'] as String),
);
}
@override
Map<String, dynamic> toJson() {
return {
'id': id,
'triggerAt': triggerAt.toIso8601String(),
'alarmType': alarmType,
};
}
}
+23
View File
@@ -5,6 +5,15 @@ class LatLng {
LatLng(this.lat, this.lng);
LatLng.empty() : lat = 0, lng = 0;
/// must be formatted 'double, double'. For example 35.35217, 89.19659
factory LatLng.fromString(String latLng) {
final splitString = latLng.split(',');
final lat = double.parse(splitString[0].trim());
final lng = double.parse(splitString[1].trim());
return LatLng(lat, lng);
}
factory LatLng.fromJson(Map<String, dynamic> json) {
return LatLng(json['lat'] as double, json['lng'] as double);
}
@@ -12,4 +21,18 @@ class LatLng {
Map<String, double> toJson() {
return {'lat': lat, 'lng': lng};
}
@override
bool operator ==(Object other) {
if (other is! LatLng) return false;
return hashCode == other.hashCode;
}
@override
int get hashCode => Object.hash(lat, lng);
@override
String toString() {
return '${lat.toString()}, ${lng.toString()}';
}
}
+17 -2
View File
@@ -1,19 +1,34 @@
import 'latlng.dart';
class Location {
final String name;
final LatLng coordinates;
final String address;
Location({required this.coordinates, this.address = ''});
Location({required this.name, required this.coordinates, this.address = ''});
factory Location.fromJson(Map<String, dynamic> json) {
return Location(
name: json['name'] as String,
address: json['address'] as String,
coordinates: LatLng.fromJson(json['coordinates']),
);
}
Map<String, dynamic> toJson() {
return {'address': address, 'coordinates': coordinates.toJson()};
return {
'name': name,
'address': address,
'coordinates': coordinates.toJson(),
};
}
@override
bool operator ==(Object other) {
if (other is! Location) return false;
return hashCode == other.hashCode;
}
@override
int get hashCode => coordinates.hashCode;
}
@@ -0,0 +1,7 @@
import '../alarm/alarm.dart' show AlarmType;
class CreateAlarmRequest {
const CreateAlarmRequest({required this.alarmType});
final AlarmType alarmType;
}
@@ -0,0 +1,17 @@
import '../alarm/alarm.dart';
import '../alarm/fixed_time_alarm.dart';
import 'create_alarm_request.dart';
class CreateFixedTimeAlarmRequest extends CreateAlarmRequest {
final DateTime? triggerAt;
const CreateFixedTimeAlarmRequest({this.triggerAt})
: super(alarmType: AlarmType.timeFixed);
CreateFixedTimeAlarmRequest.fromAlarm(FixedTimeAlarm alarm)
: triggerAt = alarm.triggerAt,
super(alarmType: AlarmType.timeFixed);
FixedTimeAlarm toFixedTimeAlarm(String id) =>
FixedTimeAlarm(id: id, triggerAt: triggerAt!);
}
@@ -0,0 +1,21 @@
import '../alarm/alarm.dart' show AlarmType;
import '../alarm/location_alarm.dart';
import '../location.dart';
import 'create_alarm_request.dart';
class CreateLocationAlarmRequest extends CreateAlarmRequest {
final Location? location;
final int? radiusMeters;
const CreateLocationAlarmRequest({this.location, this.radiusMeters})
: super(alarmType: AlarmType.location);
CreateLocationAlarmRequest.fromAlarm(LocationAlarm alarm)
: location = alarm.location,
radiusMeters = alarm.radiusMeters,
super(alarmType: AlarmType.location);
LocationAlarm toLocationAlarm(String id) =>
LocationAlarm(id: id, location: location!, radiusMeters: radiusMeters!);
}
@@ -0,0 +1,17 @@
import '../alarm/alarm.dart' show AlarmType;
import '../alarm/relative_time_alarm.dart';
import 'create_alarm_request.dart';
class CreateRelativeTimeAlarmRequest extends CreateAlarmRequest {
final DateTime? triggerAt;
const CreateRelativeTimeAlarmRequest({this.triggerAt})
: super(alarmType: AlarmType.timeRelative);
CreateRelativeTimeAlarmRequest.fromAlarm(RelativeTimeAlarm alarm)
: triggerAt = alarm.triggerAt,
super(alarmType: AlarmType.timeRelative);
RelativeTimeAlarm toRelativeTimeAlarm(String id) =>
RelativeTimeAlarm(id: id, triggerAt: triggerAt!);
}
@@ -1,4 +1,4 @@
import '../location.dart';
import '../alarm/alarm.dart';
import '../task.dart';
class CreateTaskRequest {
@@ -9,9 +9,8 @@ class CreateTaskRequest {
final bool isCompleted;
final String category;
final List<Task> subtasks;
final List<DateTime> alarms;
final Location? location;
final String url;
final List<Alarm> alarms;
CreateTaskRequest({
required this.title,
@@ -21,9 +20,8 @@ class CreateTaskRequest {
required this.isCompleted,
required this.category,
required this.subtasks,
required this.alarms,
required this.location,
required this.url,
required this.alarms,
});
CreateTaskRequest.fromTask(Task task)
@@ -34,9 +32,8 @@ class CreateTaskRequest {
isCompleted = task.isCompleted,
category = task.category,
subtasks = task.subtasks,
alarms = task.alarms,
location = task.location,
url = task.url;
url = task.url,
alarms = task.alarms;
Task toTask({required String id}) {
return Task(
@@ -48,9 +45,8 @@ class CreateTaskRequest {
isCompleted: isCompleted,
category: category,
subtasks: subtasks,
alarms: alarms,
location: location,
url: url,
alarms: alarms,
);
}
}
+8 -14
View File
@@ -1,3 +1,4 @@
import 'alarm/alarm.dart';
import 'location.dart';
class Task {
@@ -9,9 +10,8 @@ class Task {
final bool isCompleted;
final String category;
final List<Task> subtasks;
final List<DateTime> alarms;
final Location? location;
final String url;
final List<Alarm> alarms;
Task({
required this.id,
@@ -22,9 +22,8 @@ class Task {
this.isCompleted = false,
this.category = '',
this.subtasks = const [],
this.alarms = const [],
this.location,
this.url = '',
required this.alarms,
});
Task copyWith({
@@ -36,7 +35,7 @@ class Task {
bool? isCompleted,
String? category,
List<Task>? subtasks,
List<DateTime>? alarms,
List<Alarm>? alarms,
Location? location,
String? url,
}) {
@@ -49,9 +48,8 @@ class Task {
isCompleted: isCompleted ?? this.isCompleted,
category: category ?? this.category,
subtasks: subtasks ?? this.subtasks,
alarms: alarms ?? this.alarms,
location: location ?? this.location,
url: url ?? this.url,
alarms: alarms ?? this.alarms,
);
}
@@ -71,15 +69,12 @@ class Task {
?.map((e) => Task.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
url: json['url'] as String? ?? '',
alarms:
(json['alarms'] as List<dynamic>?)
?.map((e) => DateTime.parse(e as String))
?.map((e) => Alarm.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
location: json['location'] != null
? Location.fromJson(json['location'] as Map<String, dynamic>)
: null,
url: json['url'] as String? ?? '',
);
}
@@ -93,9 +88,8 @@ class Task {
'isCompleted': isCompleted,
'category': category,
'subtasks': subtasks.map((e) => e.toJson()).toList(),
'alarms': alarms.map((e) => e.toIso8601String()).toList(),
'location': location?.toJson(),
'url': url,
'alarms': alarms.map((e) => e.toJson()).toList(),
};
}
-22
View File
@@ -1,22 +0,0 @@
import 'alarm.dart';
class TimeAlarm implements Alarm {
@override
final String id;
final DateTime triggerAt;
const TimeAlarm({required this.id, required this.triggerAt});
factory TimeAlarm.fromJson(Map<String, dynamic> json) {
return TimeAlarm(
id: json['id'] as String,
triggerAt: DateTime.parse(json['triggerAt'] as String),
);
}
@override
Map<String, dynamic> toJson() {
return {'id': id, 'triggerAt': triggerAt.toIso8601String()};
}
}
+74
View File
@@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
import '../model/extensions/controller_context.dart';
import '../model/location.dart';
import '../service/controllers/location_controller.dart';
import '../widgets/dialogs/create_location_dialog.dart';
class LocationsOverviewPage extends StatefulWidget {
static const routeName = '/locations';
const LocationsOverviewPage({super.key});
@override
State<LocationsOverviewPage> createState() => _LocationsOverviewPageState();
}
class _LocationsOverviewPageState extends State<LocationsOverviewPage> {
List<Location> locations = [];
@override
Widget build(BuildContext context) {
locations = context.controller<LocationController>().locations;
return Scaffold(
appBar: AppBar(title: Text('Manage Locations')),
body: Padding(
padding: EdgeInsetsGeometry.symmetric(
horizontal: MediaQuery.of(context).size.width * 0.05,
),
child: ListView.builder(
itemBuilder: listViewBuilder,
itemCount: context.controller<LocationController>().locations.length,
),
),
floatingActionButton: FloatingActionButton(
onPressed: onAddLocationButtonPressed,
child: Icon(Icons.add),
),
);
}
Widget listViewBuilder(BuildContext context, int index) {
final location = locations.elementAt(index);
final String subtitle = location.address.isEmpty
? location.coordinates.toString()
: location.address;
return ListTile(
title: Text(location.name),
subtitle: Text(subtitle),
onTap: () => onEditLocationButtonPressed(location),
);
}
void onAddLocationButtonPressed() async {
final result = await showDialog<Location?>(
context: context,
builder: (context) => CreateLocationDialog(),
barrierDismissible: false,
);
if (mounted && result != null) {
context.controller<LocationController>().addLocation(result);
}
}
void onEditLocationButtonPressed(Location location) async {
final result = await showDialog<Location?>(
context: context,
builder: (context) => CreateLocationDialog(initialLocation: location),
barrierDismissible: false,
);
if (mounted && result != null) {
context.controller<LocationController>().updateLocation(location, result);
}
}
}
+102 -43
View File
@@ -1,10 +1,16 @@
import 'package:flutter/material.dart';
import '../model/callback_models/create_task_request.dart';
import '../app_theme.dart';
import '../model/alarm/alarm.dart';
import '../model/requests/create_alarm_request.dart';
import '../model/requests/create_fixed_time_alarm_request.dart';
import '../model/requests/create_location_alarm_request.dart';
import '../model/requests/create_task_request.dart';
import '../model/extensions/controller_context.dart';
import '../model/task.dart';
import '../service/task_controller.dart';
import '../service/controllers/task_controller.dart';
import '../service/tools.dart';
import '../widgets/alarm_widgets/alarm_form_widget.dart';
import '../widgets/time_selector.dart';
class TaskEditPage extends StatefulWidget {
@@ -31,6 +37,7 @@ class _TaskEditPageState extends State<TaskEditPage> {
final formKey = GlobalKey<FormState>(debugLabel: 'taskEditFormKey');
bool didFormChange = false;
bool isDueTimeEnabled = false;
final List<CreateAlarmRequest> alarms = [];
@override
void didChangeDependencies() {
@@ -79,44 +86,84 @@ class _TaskEditPageState extends State<TaskEditPage> {
padding: EdgeInsets.symmetric(
horizontal: MediaQuery.of(context).size.width * 0.05,
),
child: Column(
children: [
TextFormField(
autofocus: true,
controller: titleController,
decoration: InputDecoration(label: Text('Title')),
keyboardType: TextInputType.text,
textInputAction: TextInputAction.next,
),
TextFormField(
controller: descriptionController,
decoration: InputDecoration(label: Text('Description')),
keyboardType: TextInputType.multiline,
textInputAction: TextInputAction.newline,
minLines: 3,
maxLines: 10,
),
TimeSelector(
initialDueDateTime: task?.due,
nextFocusNode: categoryFocusNode,
dueDateController: dueDateController,
dueTimeController: dueTimeController,
formKey: formKey,
),
TextFormField(
focusNode: categoryFocusNode,
controller: categoryController,
decoration: InputDecoration(label: Text('Category')),
keyboardType: TextInputType.text,
textInputAction: TextInputAction.next,
),
TextFormField(
controller: urlController,
decoration: InputDecoration(label: Text('Url')),
keyboardType: TextInputType.text,
textInputAction: TextInputAction.next,
),
],
child: SingleChildScrollView(
child: Column(
spacing: AppTheme.formColumnSpacing,
children: [
SizedBox(height: 6),
TextFormField(
autofocus: true,
controller: titleController,
decoration: InputDecoration(label: Text('Title')),
keyboardType: TextInputType.text,
textInputAction: TextInputAction.next,
),
TextFormField(
controller: descriptionController,
decoration: InputDecoration(label: Text('Description')),
keyboardType: TextInputType.multiline,
textInputAction: TextInputAction.newline,
minLines: 3,
maxLines: 10,
),
TimeSelector(
initialDueDateTime: task?.due,
nextFocusNode: categoryFocusNode,
dueDateController: dueDateController,
dueTimeController: dueTimeController,
formKey: formKey,
),
TextFormField(
focusNode: categoryFocusNode,
controller: categoryController,
decoration: InputDecoration(label: Text('Category')),
keyboardType: TextInputType.text,
textInputAction: TextInputAction.next,
),
TextFormField(
controller: urlController,
decoration: InputDecoration(label: Text('Url')),
keyboardType: TextInputType.text,
textInputAction: TextInputAction.next,
),
Text(
'Alarms',
style: Theme.of(context).textTheme.headlineLarge,
),
Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
spacing: AppTheme.formColumnSpacing,
children: [
ElevatedButton.icon(
onPressed: () => onAddAlarmPressed(AlarmType.timeFixed),
label: Text('Fixed Time'),
icon: Icon(Icons.alarm_add),
),
ElevatedButton.icon(
onPressed: () =>
onAddAlarmPressed(AlarmType.timeRelative),
label: Text('Relative Time'),
icon: Icon(Icons.timer_outlined),
),
ElevatedButton.icon(
onPressed: () => onAddAlarmPressed(AlarmType.location),
label: Text('Location'),
icon: Icon(Icons.location_on),
),
for (final (index, alarm) in alarms.indexed)
AlarmForm(
request: alarm,
onChanged: (updated) {
setState(() {
alarms[index] = updated;
});
},
),
],
),
],
),
),
),
),
@@ -139,9 +186,8 @@ class _TaskEditPageState extends State<TaskEditPage> {
isCompleted: false,
category: categoryController.text,
subtasks: [],
alarms: [],
location: null,
url: urlController.text,
alarms: [],
),
);
}
@@ -167,10 +213,23 @@ class _TaskEditPageState extends State<TaskEditPage> {
],
),
).then((result) {
if (result != null && result && context.mounted) {
if (result != null && result && mounted) {
Navigator.of(context).pop();
}
});
}
}
void onAddAlarmPressed(AlarmType alarmType) {
switch (alarmType) {
case AlarmType.location:
alarms.add(CreateLocationAlarmRequest());
case AlarmType.timeFixed:
alarms.add(CreateFixedTimeAlarmRequest());
case AlarmType.timeRelative:
alarms.add(CreateFixedTimeAlarmRequest());
}
setState(() {});
}
}
+55 -24
View File
@@ -1,10 +1,12 @@
import 'package:flutter/material.dart';
import '../model/callback_models/create_task_request.dart';
import '../model/requests/create_task_request.dart';
import '../model/extensions/controller_context.dart';
import '../model/task.dart';
import '../service/task_controller.dart';
import '../service/controllers/task_controller.dart';
import '../service/tools.dart';
import '../widgets/task_dismissible.dart';
import 'locations_overview_page.dart';
import 'task_edit_page.dart';
class TaskOverviewPage extends StatefulWidget {
@@ -21,11 +23,29 @@ class _TaskOverviewPageState extends State<TaskOverviewPage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: ReorderableListView.builder(
itemBuilder: itemBuilder,
itemCount: tasks.length,
onReorderItem: context.controller<TaskController>().reorderTask,
appBar: AppBar(
title: Text('Hallo Yannick'),
actions: [
PopupMenuButton(
itemBuilder: (_) => [
PopupMenuItem(
onTap: onLocationsButtonTapped,
child: Text('Locations'),
),
],
icon: Icon(Icons.more_vert),
),
],
),
body: Padding(
padding: EdgeInsetsGeometry.symmetric(
horizontal: MediaQuery.of(context).size.width * 0.05,
),
child: ReorderableListView.builder(
itemBuilder: itemBuilder,
itemCount: tasks.length,
onReorderItem: context.controller<TaskController>().reorderTask,
),
),
floatingActionButton: FloatingActionButton(
onPressed: onCreateTaskTapped,
@@ -37,24 +57,32 @@ class _TaskOverviewPageState extends State<TaskOverviewPage> {
Widget itemBuilder(BuildContext context, int index) {
final task = tasks.elementAt(index);
return ListTile(
return Padding(
key: Key(task.id),
title: Text(task.title),
subtitle: task.description.isNotEmpty ? Text(task.description) : null,
trailing: Checkbox(
value: task.isCompleted,
onChanged: (isCompleted) => context
.controller<TaskController>()
.saveTask(task.copyWith(isCompleted: isCompleted)),
padding: const EdgeInsets.only(bottom: 12),
child: TaskDismissible(
key: Key(task.id),
onDismissedRight: () =>
context.controller<TaskController>().deleteTask(task),
child: ListTile(
title: Text(task.title),
subtitle: task.description.isNotEmpty ? Text(task.description) : null,
trailing: Checkbox(
value: task.isCompleted,
onChanged: (isCompleted) => context
.controller<TaskController>()
.saveTask(task.copyWith(isCompleted: isCompleted)),
),
onTap: () async {
final result = await onTaskTapped(task);
if (result != null && context.mounted) {
context.controller<TaskController>().saveTask(
result.toTask(id: task.id),
);
}
},
),
),
onTap: () async {
final result = await onTaskTapped(task);
if (result != null && context.mounted) {
context.controller<TaskController>().saveTask(
result.toTask(id: task.id),
);
}
},
);
}
@@ -70,10 +98,13 @@ class _TaskOverviewPageState extends State<TaskOverviewPage> {
await Navigator.of(context).pushNamed(TaskEditPage.routeName)
as CreateTaskRequest?;
if (result != null && context.mounted) {
if (result != null && mounted) {
context.controller<TaskController>().saveTask(
result.toTask(id: generateId()),
);
}
}
void onLocationsButtonTapped() =>
Navigator.of(context).pushNamed(LocationsOverviewPage.routeName);
}
@@ -1,4 +1,4 @@
import '../../alarm.dart';
import '../../model/alarm/alarm.dart';
abstract class AlarmRepository {
// Create
@@ -0,0 +1,19 @@
import '../../model/location.dart';
abstract class LocationRepository {
// Create
Future<void> createLocation(Location location);
// Read
Future<List<Location>> loadLocations();
// Update
Future<void> updateLocation(Location location);
// Delete
Future<void> deleteLocation(Location location);
}
@@ -1,4 +1,4 @@
import '../../task.dart';
import '../../model/task.dart';
abstract class TaskRepository {
// Create
@@ -2,15 +2,19 @@ import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import '../alarm.dart';
import '../task.dart';
import '../model/alarm/alarm.dart';
import '../model/location.dart';
import '../model/task.dart';
import 'interfaces/alarm_repository.dart';
import 'interfaces/location_repository.dart';
import 'interfaces/task_repository.dart';
class LocalRepository implements TaskRepository, AlarmRepository {
class LocalRepository
implements TaskRepository, AlarmRepository, LocationRepository {
static const String _tasksKey = 'tasks';
static const String _taskOrderKey = 'taskOrder';
static const String _alarmsKey = 'alarms';
static const String _locationsKey = 'locations';
SharedPreferencesWithCache? _prefs;
@@ -18,7 +22,12 @@ class LocalRepository implements TaskRepository, AlarmRepository {
if (_prefs == null) {
await SharedPreferencesWithCache.create(
cacheOptions: const SharedPreferencesWithCacheOptions(
allowList: <String>{_tasksKey, _taskOrderKey},
allowList: <String>{
_tasksKey,
_taskOrderKey,
_alarmsKey,
_locationsKey,
},
),
).then((value) => _prefs = value);
}
@@ -29,6 +38,13 @@ class LocalRepository implements TaskRepository, AlarmRepository {
_prefs!.setStringList(_tasksKey, jsonList);
}
Future<void> _saveLocations(List<Location> locations) async {
final jsonList = locations
.map<String>((e) => jsonEncode(e.toJson()))
.toList();
_prefs!.setStringList(_locationsKey, jsonList);
}
Future<void> _saveTaskOrder(List<String> taskOrder) async {
final jsonList = taskOrder.map((e) => jsonEncode(e)).toList();
return _prefs!.setStringList(_taskOrderKey, jsonList);
@@ -62,6 +78,13 @@ class LocalRepository implements TaskRepository, AlarmRepository {
_saveAlarms(alarms);
}
@override
Future<void> createLocation(Location location) async {
final locations = await loadLocations();
locations.add(location);
_saveLocations(locations);
}
// Read
@override
@@ -83,6 +106,15 @@ class LocalRepository implements TaskRepository, AlarmRepository {
return jsonList.map<Alarm>((e) => Alarm.fromJson(jsonDecode(e))).toList();
}
@override
Future<List<Location>> loadLocations() async {
final Iterable<String> jsonList =
_prefs!.getStringList(_locationsKey) ?? [];
return jsonList
.map<Location>((e) => Location.fromJson(jsonDecode(e)))
.toList();
}
// Update
@override
@@ -106,6 +138,14 @@ class LocalRepository implements TaskRepository, AlarmRepository {
_saveAlarms(alarms);
}
@override
Future<void> updateLocation(Location location) async {
final locations = await loadLocations();
locations.remove(location);
locations.add(location);
_saveLocations(locations);
}
// Delete
@override
@@ -128,4 +168,11 @@ class LocalRepository implements TaskRepository, AlarmRepository {
alarms.remove(alarm);
_saveAlarms(alarms);
}
@override
Future<void> deleteLocation(Location location) async {
final locations = await loadLocations();
locations.remove(location);
_saveLocations(locations);
}
}
@@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
import '../../model/alarm/alarm.dart';
import '../../repositories/interfaces/alarm_repository.dart';
class AlarmController extends ChangeNotifier {
AlarmController(AlarmRepository repository) : _repository = repository {
_loadAlarms();
}
final AlarmRepository _repository;
final List<Alarm> _alarms = [];
Future<void> addAlarm(Alarm alarm) {
_alarms.add(alarm);
notifyListeners();
return _repository.createAlarm(alarm);
}
Future<void> deleteAlarm(Alarm alarm) {
_alarms.remove(alarm);
notifyListeners();
return _repository.deleteAlarm(alarm);
}
Future<void> _loadAlarms() {
_alarms.clear();
return _repository.loadAlarms().then((value) => _alarms.addAll(value));
}
}
@@ -0,0 +1,45 @@
import 'package:flutter/material.dart';
import '../../model/location.dart';
import '../../repositories/interfaces/location_repository.dart';
class LocationController extends ChangeNotifier {
LocationController(LocationRepository repository) : _repository = repository {
_loadLocations();
}
final LocationRepository _repository;
final List<Location> _locations = [];
List<Location> get locations => _locations;
Future<void> addLocation(Location location) {
_locations.add(location);
notifyListeners();
return _repository.createLocation(location);
}
Future<void> deleteLocation(Location location) {
_locations.remove(location);
notifyListeners();
return _repository.deleteLocation(location);
}
Future<void> updateLocation(Location oldLocation, Location newLocation) {
final index = _locations.indexOf(oldLocation);
_locations.remove(oldLocation);
_locations.insert(index, newLocation);
notifyListeners();
return _repository
.deleteLocation(oldLocation)
.whenComplete(() => _repository.createLocation(newLocation));
}
Future<void> _loadLocations() {
_locations.clear();
return _repository.loadLocations().then(
(value) => _locations.addAll(value),
);
}
}
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart' show ChangeNotifier;
import '../model/repositories/interfaces/task_repository.dart';
import '../model/task.dart';
import '../../repositories/interfaces/task_repository.dart';
import '../../model/task.dart';
class TaskController extends ChangeNotifier {
TaskController(TaskRepository repository) : _repository = repository {
+23
View File
@@ -7,8 +7,31 @@ String? timeValidator(String? value) {
return 'Not a valid time format';
}
String? wholeNumberValidator(String? value) {
if (value == null || value.isEmpty) return null;
if (RegExp(r'^[0-9]*$').hasMatch(value)) {
return null;
}
return 'Not a valid number';
}
String? dateTimeValidator(String? value) {
if (value == null || value.isEmpty) return null;
return DateTime.tryParse(value) != null ? null : 'Not a date format';
}
String? coordinatesValidator(String? value) {
if (value == null || value.isEmpty) return null;
if (RegExp(r'^\d+\.?\d*, *\d+\.?\d*$').hasMatch(value)) {
return null;
}
return 'Not a valid coordinate format';
}
String? notEmptyValidator(String? value) {
if (value != null && value.isNotEmpty) return null;
return 'Can\'t be empty';
}
@@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
import '../../model/alarm/alarm.dart' show AlarmType;
import '../../model/requests/create_alarm_request.dart';
import '../../model/requests/create_fixed_time_alarm_request.dart';
import '../../model/requests/create_location_alarm_request.dart';
import '../../model/requests/create_relative_time_alarm_request.dart';
import 'fixed_time_alarm_form.dart';
import 'location_alarm_form.dart';
import 'relative_time_alarm_form.dart';
class AlarmForm extends StatelessWidget {
const AlarmForm({super.key, required this.request, required this.onChanged});
final CreateAlarmRequest request;
final ValueChanged<CreateAlarmRequest> onChanged;
@override
Widget build(BuildContext context) {
switch (request.alarmType) {
case AlarmType.timeFixed:
return FixedTimeAlarmForm(
request: request as CreateFixedTimeAlarmRequest,
onChanged: onChanged,
);
case AlarmType.timeRelative:
return RelativeTimeAlarmForm(
request: request as CreateRelativeTimeAlarmRequest,
onChanged: onChanged,
);
case AlarmType.location:
return LocationAlarmForm(
request: request as CreateLocationAlarmRequest,
onChanged: onChanged,
);
}
}
}
@@ -0,0 +1,160 @@
import 'package:flutter/material.dart';
import '../../app_theme.dart' show AppTheme;
import '../../model/requests/create_fixed_time_alarm_request.dart';
import '../../service/tools.dart';
import '../../service/validators.dart';
class FixedTimeAlarmForm extends StatefulWidget {
const FixedTimeAlarmForm({
super.key,
required this.request,
required this.onChanged,
});
final CreateFixedTimeAlarmRequest request;
final ValueChanged<CreateFixedTimeAlarmRequest> onChanged;
@override
State<FixedTimeAlarmForm> createState() => _FixedTimeAlarmFormState();
}
class _FixedTimeAlarmFormState extends State<FixedTimeAlarmForm> {
final dueDateController = TextEditingController();
final dueTimeController = TextEditingController();
final dueDateFocusNode = FocusNode();
final dueTimeFocusNode = FocusNode();
bool isDueTimeEnabled = false;
@override
void initState() {
dueDateController.addListener(onDueDateChanged);
dueTimeController.addListener(onDueTimeChanged);
super.initState();
}
@override
Widget build(BuildContext context) {
return Flex(
direction: Axis.horizontal,
children: [
Flexible(
flex: 3,
child: TextFormField(
focusNode: dueDateFocusNode,
controller: dueDateController,
onFieldSubmitted: (_) {
isDueTimeEnabled ? dueDateFocusNode.nextFocus() : null;
},
decoration: InputDecoration(
label: Text('Due Date'),
suffixIcon: IconButton(
onPressed: () async {
final result = await onOpenCalendarPickerPressed();
if (result != null) {
final dateString = getIsoDateString(result);
dueDateController.text = dateString;
maybeEnableDueTime(dateString);
dueTimeFocusNode.requestFocus();
}
},
icon: Icon(Icons.calendar_month),
),
),
validator: dateTimeValidator,
keyboardType: TextInputType.datetime,
textInputAction: TextInputAction.next,
),
),
Padding(
padding: EdgeInsetsGeometry.only(left: AppTheme.formColumnSpacing),
),
Flexible(
flex: 2,
child: TextFormField(
focusNode: dueTimeFocusNode,
onFieldSubmitted: (_) => dueTimeFocusNode.nextFocus(),
controller: dueTimeController,
enabled: isDueTimeEnabled,
onChanged: (value) {},
decoration: InputDecoration(
label: Text('Due Time'),
suffixIcon: IconButton(
onPressed: () async {
final result = await onOpenTimePickerPressed();
if (result != null && context.mounted) {
dueTimeController.text = result.format(context);
}
},
icon: Icon(Icons.schedule),
),
),
validator: timeValidator,
keyboardType: TextInputType.text,
textInputAction: TextInputAction.next,
),
),
],
);
}
Future<DateTime?> onOpenCalendarPickerPressed() {
return showDialog<DateTime?>(
context: context,
builder: (context) => DatePickerDialog(
firstDate: DateTime(DateTime.now().year - 100),
lastDate: DateTime(DateTime.now().year + 100),
initialDate:
DateTime.tryParse(dueDateController.text) ?? DateTime.now(),
),
);
}
Future<TimeOfDay?> onOpenTimePickerPressed() {
return showDialog<TimeOfDay?>(
context: context,
builder: (context) => TimePickerDialog(
initialTime: TimeOfDay.fromDateTime(
widget.request.triggerAt ?? DateTime.now(),
),
),
);
}
void maybeEnableDueTime(String value) {
if (value.isNotEmpty && dateTimeValidator(value) == null) {
setState(() {
isDueTimeEnabled = true;
});
} else if (isDueTimeEnabled) {
setState(() {
isDueTimeEnabled = false;
});
}
}
void onDueDateChanged() {
final String value = dueDateController.text;
maybeEnableDueTime(value);
if (dateTimeValidator(value) == null) {
final dateTime = DateTime.tryParse(
'$value ${dueTimeController.text}'.trim(),
);
if (dateTime != null) {
widget.onChanged(CreateFixedTimeAlarmRequest(triggerAt: dateTime));
}
}
}
void onDueTimeChanged() {
final String value = dueTimeController.text;
if (timeValidator(value) == null) {
final dateTime = DateTime.tryParse(
'${dueDateController.text} $value'.trim(),
);
if (dateTime != null) {
widget.onChanged(CreateFixedTimeAlarmRequest(triggerAt: dateTime));
}
}
}
}
@@ -0,0 +1,19 @@
import 'package:flutter/material.dart';
import '../../model/requests/create_alarm_request.dart';
import '../../model/requests/create_location_alarm_request.dart';
class LocationAlarmForm extends StatelessWidget {
const LocationAlarmForm({
super.key,
required this.request,
required this.onChanged,
});
final CreateLocationAlarmRequest request;
final ValueChanged<CreateAlarmRequest> onChanged;
@override
Widget build(BuildContext context) {
return const Placeholder();
}
}
@@ -0,0 +1,19 @@
import 'package:flutter/material.dart';
import '../../model/requests/create_alarm_request.dart';
import '../../model/requests/create_relative_time_alarm_request.dart';
class RelativeTimeAlarmForm extends StatelessWidget {
const RelativeTimeAlarmForm({
super.key,
required this.request,
required this.onChanged,
});
final CreateRelativeTimeAlarmRequest request;
final ValueChanged<CreateAlarmRequest> onChanged;
@override
Widget build(BuildContext context) {
return const Placeholder();
}
}
@@ -0,0 +1,93 @@
import 'package:flutter/material.dart';
import '../../app_theme.dart';
import '../../model/latlng.dart';
import '../../model/location.dart';
import '../../service/validators.dart';
class CreateLocationDialog extends StatefulWidget {
const CreateLocationDialog({super.key, this.initialLocation});
final Location? initialLocation;
@override
State<CreateLocationDialog> createState() => _CreateLocationDialogState();
}
class _CreateLocationDialogState extends State<CreateLocationDialog> {
final nameController = TextEditingController();
final addressController = TextEditingController();
final coordinatesController = TextEditingController();
final formKey = GlobalKey<FormState>(debugLabel: 'Create Location Form');
@override
void initState() {
if (widget.initialLocation != null) {
nameController.text = widget.initialLocation!.name;
addressController.text = widget.initialLocation!.address;
coordinatesController.text = widget.initialLocation!.coordinates
.toString();
}
super.initState();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
actions: [
TextButton(onPressed: onCancelPressed, child: Text('Cancel')),
TextButton(onPressed: onSavePressed, child: Text('Save')),
],
title: Text('Create Location'),
content: Form(
key: formKey,
child: Column(
spacing: AppTheme.formColumnSpacing,
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
autofocus: true,
textInputAction: TextInputAction.next,
controller: nameController,
keyboardType: TextInputType.text,
decoration: InputDecoration(labelText: 'Name'),
validator: notEmptyValidator,
),
TextFormField(
textInputAction: TextInputAction.next,
controller: addressController,
keyboardType: TextInputType.streetAddress,
decoration: InputDecoration(labelText: 'Address (optional)'),
),
TextFormField(
textInputAction: TextInputAction.done,
controller: coordinatesController,
onFieldSubmitted: (_) => onSavePressed(),
keyboardType: TextInputType.numberWithOptions(),
decoration: InputDecoration(
labelText: 'Coordinates',
hint: Text('25.5892, 50.5051662'),
),
validator: (value) {
return notEmptyValidator(value) ?? coordinatesValidator(value);
},
),
],
),
),
);
}
void onCancelPressed() => Navigator.of(context).pop();
void onSavePressed() {
if (formKey.currentState!.validate()) {
Navigator.of(context).pop(
Location(
name: nameController.text,
coordinates: LatLng.fromString(coordinatesController.text),
address: addressController.text,
),
);
}
}
}
+44
View File
@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
class TaskDismissible extends StatelessWidget {
const TaskDismissible({
required super.key,
this.onDismissedRight,
required this.child,
});
final VoidCallback? onDismissedRight;
final Widget child;
@override
Widget build(BuildContext context) {
return Dismissible(
key: key!,
direction: DismissDirection.startToEnd,
background: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: Theme.of(context).colorScheme.error,
),
child: Align(
alignment: AlignmentGeometry.centerLeft,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Icon(
Icons.delete,
color: Theme.of(context).colorScheme.onError,
),
),
),
),
onDismissed: onDismissed,
child: child,
);
}
void onDismissed(DismissDirection direction) {
if (direction == DismissDirection.startToEnd && onDismissedRight != null) {
onDismissedRight!();
}
}
}
+28 -2
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import '../app_theme.dart';
import '../service/tools.dart' show getIsoDateString;
import '../service/validators.dart';
@@ -11,6 +12,7 @@ class TimeSelector extends StatefulWidget {
required this.dueDateController,
required this.dueTimeController,
this.formKey,
this.onChanged,
});
final DateTime? initialDueDateTime;
@@ -18,6 +20,7 @@ class TimeSelector extends StatefulWidget {
final FocusNode? nextFocusNode;
final TextEditingController dueDateController;
final TextEditingController dueTimeController;
final void Function(DateTime dateTime)? onChanged;
@override
State<TimeSelector> createState() => _TimeSelectorState();
@@ -47,7 +50,18 @@ class _TimeSelectorState extends State<TimeSelector> {
child: TextFormField(
focusNode: dueDateFocusNode,
controller: widget.dueDateController,
onChanged: maybeEnableDueTime,
onChanged: (value) {
maybeEnableDueTime(value);
if (dateTimeValidator(value) == null &&
widget.onChanged != null) {
final dateTime = DateTime.tryParse(
'$value ${widget.dueTimeController.text}'.trim(),
);
if (dateTime != null) {
widget.onChanged!(dateTime);
}
}
},
onFieldSubmitted: (_) {
isDueTimeEnabled
? dueDateFocusNode.nextFocus()
@@ -74,7 +88,9 @@ class _TimeSelectorState extends State<TimeSelector> {
textInputAction: TextInputAction.next,
),
),
Padding(padding: EdgeInsetsGeometry.only(left: 10)),
Padding(
padding: EdgeInsetsGeometry.only(left: AppTheme.formColumnSpacing),
),
Flexible(
flex: 2,
child: TextFormField(
@@ -82,6 +98,16 @@ class _TimeSelectorState extends State<TimeSelector> {
onFieldSubmitted: (_) => dueTimeFocusNode.nextFocus(),
controller: widget.dueTimeController,
enabled: isDueTimeEnabled,
onChanged: (value) {
if (timeValidator(value) == null && widget.onChanged != null) {
final dateTime = DateTime.tryParse(
'${widget.dueDateController.text} $value'.trim(),
);
if (dateTime != null) {
widget.onChanged!(dateTime);
}
}
},
decoration: InputDecoration(
label: Text('Due Time'),
suffixIcon: IconButton(