mirror of
https://github.com/johrpan/memor.git
synced 2025-10-28 03:07:25 +01:00
Initial commit
This commit is contained in:
commit
4be8aa8ff5
47 changed files with 1577 additions and 0 deletions
27
lib/app.dart
Normal file
27
lib/app.dart
Normal 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
176
lib/backend.dart
Normal 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
41
lib/date_utils.dart
Normal 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
121
lib/home_screen.dart
Normal 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
12
lib/main.dart
Normal 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
41
lib/memo.dart
Normal 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
125
lib/memo_editor.dart
Normal 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;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue