Initial commit

This commit is contained in:
Elias Projahn 2020-05-21 20:25:25 +02:00
commit 4be8aa8ff5
47 changed files with 1577 additions and 0 deletions

27
lib/app.dart Normal file
View file

@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
import 'home_screen.dart';
/// A simple reminder app.
///
/// This has to be wrapped by a MemorBackend widget.
class MemorApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(
primaryColor: Colors.black,
accentColor: Colors.amber,
textSelectionColor: Colors.amber,
cursorColor: Colors.amber,
textSelectionHandleColor: Colors.amber,
colorScheme: ColorScheme.light(
primary: Colors.black,
secondary: Colors.amber,
),
fontFamily: 'Libertinus Sans',
),
home: HomeScreen(),
);
}
}

176
lib/backend.dart Normal file
View file

@ -0,0 +1,176 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart' as pp;
import 'package:rxdart/rxdart.dart';
import 'memo.dart';
/// Widget for managing resources and state for Memor.
///
/// This should be near the top of the widget tree and provide other widgets
/// with the globally shared resources and state.
class MemorBackend extends StatefulWidget {
/// Retrieve the current backend state.
static MemorBackendState of(BuildContext context) => context
.dependOnInheritedWidgetOfExactType<_InheritedMemorBackend>()
.state;
/// The next widget down the tree.
///
/// Descendants can get the current state by calling [of].
final Widget child;
MemorBackend({
@required this.child,
});
@override
MemorBackendState createState() => MemorBackendState();
}
class MemorBackendState extends State<MemorBackend> {
/// This will always contain the current list of memos.
///
/// The memos will be ordered by their scheduled time.
final memos = BehaviorSubject.seeded(<Memo>[]);
/// Whether the backend is currently loading.
///
/// If this is true, the UI should not call backend methods.
bool loading = true;
static const _fileName = 'memos.json';
static const _notificationDetails = NotificationDetails(
AndroidNotificationDetails('memor', 'Memor', 'Memor reminders'),
IOSNotificationDetails());
final _notifications = FlutterLocalNotificationsPlugin();
File _file;
@override
void initState() {
super.initState();
_load();
}
/// Initialize resources and load memos from disk.
Future<void> _load() async {
await _notifications.initialize(InitializationSettings(
AndroidInitializationSettings('ic_memor'),
IOSInitializationSettings()));
final _baseDirectory = await pp.getApplicationDocumentsDirectory();
_file = File(p.join(_baseDirectory.path, _fileName));
if (await _file.exists()) {
final contents = await _file.readAsString();
final List<Map<String, dynamic>> json = List.from(jsonDecode(contents));
List<Memo> newMemos = [];
for (final memoJson in json) {
newMemos.add(Memo.fromJson(memoJson));
}
memos.add(newMemos);
}
setState(() {
loading = false;
});
}
/// Save memos to disk.
Future<void> _save() async {
final json = memos.value.map((m) => m.toJson()).toList();
await _file.writeAsString(jsonEncode(json));
}
/// Add a memo to the list.
///
/// This will sort the list and update the stream afterwards. A notification
/// will be scheduled for the new memo.
Future<void> addMemo(Memo memo) async {
final List<Memo> newMemos = List.from(memos.value);
newMemos.add(memo);
newMemos.sort((m1, m2) => m1.scheduled.compareTo(m2.scheduled));
memos.add(newMemos);
await _schedule(memo);
await _save();
}
/// Delete a memo by its index.
///
/// This will update the stream afterwards. The scheduled notification will
/// be canceled.
Future<void> deleteMemo(int index) async {
final List<Memo> newMemos = List.from(memos.value);
final memo = newMemos.removeAt(index);
memos.add(newMemos);
await _notifications.cancel(memo.id);
await _save();
}
/// Replace a memo by its index.
///
/// This will sort the list and update the stream afterwards. The scheduled
/// notification for the old memo will be canceled and rescheduled.
Future<void> updateMemo(int index, Memo memo) async {
final List<Memo> newMemos = List.from(memos.value);
final oldMemo = newMemos[index];
await _notifications.cancel(oldMemo.id);
newMemos[index] = memo;
newMemos.sort((m1, m2) => m1.scheduled.compareTo(m2.scheduled));
memos.add(newMemos);
await _schedule(memo);
await _save();
}
/// Schedule a notification for a memo.
Future<void> _schedule(Memo memo) async {
_notifications.schedule(
memo.id,
'Reminder',
memo.text,
memo.scheduled,
_notificationDetails,
androidAllowWhileIdle: true,
);
}
@override
Widget build(BuildContext context) {
return _InheritedMemorBackend(
state: this,
child: widget.child,
);
}
@override
void dispose() {
super.dispose();
memos.close();
}
}
/// Helper widget to pass the current backend state down the widget tree.
class _InheritedMemorBackend extends InheritedWidget {
/// The current backend state.
final MemorBackendState state;
/// The next widget down the tree.
final Widget child;
_InheritedMemorBackend({
@required this.state,
@required this.child,
});
@override
bool updateShouldNotify(InheritedWidget oldWidget) => true;
}

41
lib/date_utils.dart Normal file
View file

@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
/// Utilities for handling DateTime objects.
extension DateUtils on DateTime {
/// Create a new instance with identical values.
DateTime copy() => DateTime(
this.year,
this.month,
this.day,
this.hour,
this.minute,
);
/// Create a new instance with the same date but a different time.
DateTime copyWithTime(TimeOfDay time) => DateTime(
this.year,
this.month,
this.day,
time.hour,
time.minute,
);
/// Get a string representation of the represented day suitable for display.
String get dateString {
final now = DateTime.now();
if (this.year == now.year && this.month == now.month) {
if (this.day == now.day) {
return 'Today';
} else if (this.day == now.day + 1) {
return 'Tomorrow';
}
}
final format = DateFormat.yMd();
return format.format(this);
}
/// Get the time of day represented by this object.
TimeOfDay get timeOfDay => TimeOfDay.fromDateTime(this);
}

121
lib/home_screen.dart Normal file
View file

@ -0,0 +1,121 @@
import 'package:flutter/material.dart';
import 'backend.dart';
import 'date_utils.dart';
import 'memo.dart';
import 'memo_editor.dart';
/// The Memor home screen.
///
/// The screen shows nothing more than a list of scheduled memos. They can be
/// dismissed by swiping and edited by tapping.
class HomeScreen extends StatelessWidget {
Future<Memo> _showMemoEditor(BuildContext context, [Memo memo]) async {
return await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => MemoEditor(
memo: memo,
),
fullscreenDialog: true,
),
);
}
@override
Widget build(BuildContext context) {
final backend = MemorBackend.of(context);
return Scaffold(
appBar: AppBar(
title: Text('Memor'),
),
body: backend.loading
? Center(
child: CircularProgressIndicator(),
)
: StreamBuilder<List<Memo>>(
stream: backend.memos,
builder: (context, snapshot) {
if (snapshot.hasData) {
final memos = snapshot.data;
if (memos.isNotEmpty) {
return ListView.builder(
itemCount: memos.length,
itemBuilder: (context, index) {
final memo = memos[index];
final scheduled = memo.scheduled;
final dateString = scheduled.dateString;
final timeOfDayString =
scheduled.timeOfDay.format(context);
return Dismissible(
key: ValueKey(memo.id),
secondaryBackground: Container(
padding: const EdgeInsets.all(8.0),
alignment: Alignment.centerRight,
color: Colors.amber,
child: const Icon(Icons.done),
),
background: Container(
padding: const EdgeInsets.all(8.0),
alignment: Alignment.centerLeft,
color: Colors.amber,
child: const Icon(Icons.done),
),
child: ListTile(
title: Text(memo.text),
subtitle: Text('$dateString at $timeOfDayString'),
onTap: () async {
final result =
await _showMemoEditor(context, memo);
if (result != null) {
await backend.updateMemo(index, result);
}
},
),
onDismissed: (_) async {
await backend.deleteMemo(index);
Scaffold.of(context).showSnackBar(
SnackBar(
content: Text('Deleted "${memo.text}"'),
action: SnackBarAction(
label: 'UNDO',
onPressed: () async {
await backend.addMemo(memo);
},
),
),
);
},
);
},
);
} else {
return Center(
child: Text(
'No reminders scheduled',
style: Theme.of(context).textTheme.headline6.copyWith(
color: Colors.grey,
),
),
);
}
} else {
return Container();
}
},
),
floatingActionButton: FloatingActionButton(
child: const Icon(Icons.add),
onPressed: () async {
final result = await _showMemoEditor(context);
if (result != null) {
await backend.addMemo(result);
}
},
),
);
}
}

12
lib/main.dart Normal file
View file

@ -0,0 +1,12 @@
import 'package:flutter/widgets.dart';
import 'backend.dart';
import 'app.dart';
void main() {
runApp(
MemorBackend(
child: MemorApp(),
),
);
}

41
lib/memo.dart Normal file
View file

@ -0,0 +1,41 @@
import 'dart:math';
import 'package:meta/meta.dart';
/// A simple memo.
///
/// This is the model to represent Memor's reminders.
class Memo {
/// An unique ID for the memo.
///
/// This will be used for scheduling the notification. It will be generated,
/// if set to null.
final int id;
/// The actual memo set by the user.
final String text;
/// The date and time for the scheduled notification.
final DateTime scheduled;
static final _random = Random();
static int _generateId() => _random.nextInt(0xFFFFFFF);
Memo({
int id,
@required this.text,
@required this.scheduled,
}) : id = id ?? _generateId();
factory Memo.fromJson(Map<String, dynamic> json) => Memo(
id: json['id'],
text: json['text'],
scheduled: DateTime.parse(json['scheduled']),
);
Map<String, dynamic> toJson() => {
'id': id,
'text': text,
'scheduled': scheduled.toIso8601String(),
};
}

125
lib/memo_editor.dart Normal file
View file

@ -0,0 +1,125 @@
import 'package:flutter/material.dart';
import 'memo.dart';
import 'date_utils.dart';
/// A screen for editing or creating a memo.
///
/// The new memo will be returned when popping the navigator. A return value of
/// null indicates that the user has canceled the editor.
class MemoEditor extends StatefulWidget {
/// The memo to edit.
///
/// A value of null indicates that the user will be creating a new memo.
final Memo memo;
MemoEditor({
this.memo,
});
@override
_MemoEditorState createState() => _MemoEditorState();
}
class _MemoEditorState extends State<MemoEditor> {
final _textController = TextEditingController();
DateTime _date;
TimeOfDay _time;
@override
void initState() {
super.initState();
if (widget.memo != null) {
_textController.text = widget.memo.text;
_date = widget.memo.scheduled.copy();
_time = TimeOfDay.fromDateTime(_date);
} else {
_date = DateTime.now().add(Duration(days: 1));
_time = TimeOfDay(
hour: 8,
minute: 0,
);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: Text(widget.memo != null ? 'Edit memo' : 'Add memo'),
actions: <Widget>[
FlatButton(
child: Text(
widget.memo != null ? 'SAVE' : 'CREATE',
style: theme.textTheme.button.copyWith(
color: theme.colorScheme.onPrimary,
),
),
onPressed: () {
Navigator.pop(
context,
Memo(
text: _textController.text,
scheduled: _date.copyWithTime(_time),
),
);
},
),
],
),
body: ListView(
children: <Widget>[
Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
controller: _textController,
maxLines: null,
decoration: InputDecoration(
border: OutlineInputBorder(),
labelText: 'Memo',
),
),
),
ListTile(
title: Text('Date'),
subtitle: Text(_date.dateString),
onTap: () async {
final result = await showDatePicker(
context: context,
initialDate: _date,
firstDate: DateTime.now(),
lastDate: DateTime.now().add(Duration(days: 3560)),
);
if (result != null) {
setState(() {
_date = result;
});
}
},
),
ListTile(
title: Text('Time'),
subtitle: Text(_time.format(context)),
onTap: () async {
final result = await showTimePicker(
context: context,
initialTime: _time,
);
if (result != null) {
setState(() {
_time = result;
});
}
},
),
],
),
);
}
}