Dogly - a mobile app with Flutter: calendar
Introduction
In this post, I will create the last feature of the application - the calendar. The user should be able to add the following events to the calendar: walks, meals, and vet visits. The events should be displayed as dots under the date in the calendar (one dot represents one event). If a user clicks on the date, the list of events with a timestamp and the possibility to edit or display information should appear under the calendar. It does not look complicated, but it needs to handle three different models, so in fact, I will create four features - walks, meals, visits, and a calendar. The chunk of the directory tree will look like this:
features/
├── calendar/
│ ├── application/
│ │ └── calendar_service.dart
│ └── presentation/
│ ├── controller/
│ │ └── calendar_controler.dart
│ ├── view/
│ │ └── calendar_view.dart
│ └── widgets/
│ ├── calendar.dart
│ ├── events_bottom_sheet.dart
│ └── events_list.dart
└── meals/
├── application/
│ └── meals_service.dart
├── data/
│ ├── local_meal_api.dart
│ └── remote_meal_api.dart
├── domain/
│ ├── meal_model.dart
│ └── meal_model.g.dart
└── presentation/
├── controller/
│ └── meals_controller.dart
├── view/
│ ├── add_meal_view.dart
│ └── edit_meal_view.dart
└── widgets/
├── meal_dialog.dart
└── meal_tile.dart
As you can see the calendar feature will have only the application and presentation layers. The whole data part and UI connected directly with events will be implemented in the events directories (by events I mean visits, meals, and walks). The post could be quite long, but the implementation for the three additional features is very similar, so I will try to go through it as fast as possible. This is also the last post from the series.
Let’s get to work!
Domain Layer
Meal model
A meal model should have the following attributes:
- id
- meal id
- start time
- user id
- dog id
- name
It has two separate IDs. The id field is a key for Isar DB and meal id
for Appwrite. As you know from the fourth post of the series I make it in this way, because they have different types. Isar nicely generates the integer key if the Id
type is used in the model. The Appwrite uses string keys.
import 'package:isar/isar.dart';
part "meal_model.g.dart";
@collection
class MealModel {
Id? id;
final DateTime startTime;
final String? uid;
final String name;
final String? mid;
final String? did;
MealModel(
{this.id,
required this.startTime,
required this.uid,
required this.name,
this.mid,
required this.did});
MealModel copyWith(
{Id? id,
DateTime? startTime,
String? uid,
String? name,
String? mid,
String? did}) {
return MealModel(
id: this.id,
startTime: startTime ?? this.startTime,
uid: uid ?? this.uid,
name: name ?? this.name,
mid: mid ?? this.mid,
did: did ?? this.did,
);
}
Map<String, dynamic> toMap() {
final result = <String, dynamic>{};
result.addAll({'startTime': startTime.toIso8601String()});
result.addAll({'uid': uid});
result.addAll({'name': name});
result.addAll({'did': did});
return result;
}
factory MealModel.fromMap(Map<dynamic, dynamic> map) {
return MealModel(
startTime: DateTime.parse(map['startTime']),
uid: map['uid'] ?? '',
name: map['name'] ?? '',
mid: map['\$id'] ?? '',
did: map['did'] ?? '',
);
}
}
The other methods are the standard ones for updating the entities and serialization.
Walk model
The walk model shall implement the following fields:
- id
- walk id
- start time
- end time
- user id
- steps
- dogs ids
The implementation of the walk model looks like this:
import 'package:isar/isar.dart';
part "walk_model.g.dart";
@collection
class WalkModel {
Id? id;
final DateTime startTime;
final String? uid;
final int steps;
final DateTime endTime;
final String? wid;
List<String?> dogsIds;
WalkModel(
{this.id,
required this.startTime,
required this.uid,
required this.steps,
required this.endTime,
this.wid,
required this.dogsIds});
WalkModel copyWith({
Id? id,
DateTime? startTime,
String? uid,
int? steps,
DateTime? endTime,
String? wid,
List<String?>? dogsIds,
}) {
return WalkModel(
id: this.id,
startTime: startTime ?? this.startTime,
uid: uid ?? this.uid,
steps: steps ?? this.steps,
endTime: endTime ?? this.endTime,
wid: wid ?? this.wid,
dogsIds: dogsIds ?? this.dogsIds,
);
}
Map<String, dynamic> toMap() {
final result = <String, dynamic>{};
result.addAll({'startTime': startTime.toIso8601String()});
result.addAll({'uid': uid});
result.addAll({'steps': steps});
result.addAll({'endTime': endTime.toIso8601String()});
result.addAll({'dogsIds': dogsIds});
return result;
}
factory WalkModel.fromMap(Map<dynamic, dynamic> map) {
return WalkModel(
startTime: DateTime.parse(map['startTime']),
uid: map['uid'] ?? '',
steps: map['steps'] ?? '',
endTime: DateTime.parse(map['endTime']),
wid: map['wid'] ?? '',
dogsIds: (map['dogsIds'] as List).map((e) => e as String).toList());
}
}
You can notice the models have an attribute with the collection type - a list of strings. If someone has more than one dog they can attend in one walk. In the standard relationship model, I could create a join table for this problem for example, but both databases used in the Dogly follow the document model. At the moment of creating the app, Appwrite has a relationships feature, but it is still in the beta and has a lot of limitations. Isar offers the links, but I think it is quite overkill for my use case and could complicate the compatibility between two database systems in the code. I decided to make a simple implementation. The list contains the ids of the dogs and if some part of the application needs to know which dog is it the proper method from the dogs feature must be executed.
Visits model
The visits model ought to contain the following information:
- id
- visit id
- user id
- dog id
- start time
- note
The implementation is pretty similar to the previous one, so I won’t copy-paste it here again.
Now the Isar schema files can be created with the generator:
flutter pub run build_runner build
Data layer
In this section, I will only cover the data layer for meals API. The visits and walks API has the same methods, they just use different collections. I was wondering if I could make just one implementation with the argument to choose which collection I ask, but I guess this is not the best pattern especially if the app grows
Both local and remote APIs will implement standard CRUD methods and also the methods for obtaining all user meals and meals within the specified dates. The last one will be useful only for getting the events within the specific day in the calendar. The widget I will use always gives the date with a first timestamp (00:00:00). So I will ask the databases for all events with a timestamp between the given date and a date increased by one day. The method won’t be useful for the date range pickers for example, but I do not plan to have them.
Local API
Let’s start with creating an abstract interface and a provider.
final localMealAPIProvider =
Provider((ref) => LocalMealAPI(db: ref.watch(isarInstanceProvider)));
abstract class IlocalMealAPII {
FutureEitherVoid saveMealData(MealModel walkModel);
Future<List<MealModel?>> getUserMeals();
FutureEitherVoid updateMealData(MealModel walkModel);
FutureEitherVoid deleteMealData(MealModel walkModel);
Future<List<MealModel>> getUserMealsWithinDates(
DateTime startDate, DateTime endDate);
}
The provider watches for the Isar instance provider created in the core directory. Now the class can implement the interface.
final Isar _db;
LocalMealAPI({required Isar db}) : _db = db;
@override
FutureEitherVoid deleteMealData(MealModel walkModel) async {
try {
await _db.writeTxn(() async {
await _db.collection<MealModel>().delete(walkModel.id!);
});
return right(null);
} on IsarError catch (e, st) {
return left(Failure(e.message, st));
} catch (e, st) {
return left(Failure(e.toString(), st));
}
}
@override
Future<List<MealModel?>> getUserMeals() async {
final docList = await _db.collection<MealModel>().where().findAll();
return docList;
}
@override
FutureEitherVoid saveMealData(MealModel walkModel) async {
try {
await _db.writeTxn(() async {
await _db.collection<MealModel>().put(walkModel);
});
return right(null);
} on IsarError catch (e, st) {
return left(Failure(e.message, st));
} catch (e, st) {
return left(Failure(e.toString(), st));
}
}
@override
FutureEitherVoid updateMealData(MealModel walkModel) async {
try {
await _db.writeTxn(() async {
await _db.collection<MealModel>().put(walkModel);
});
return right(null);
} on IsarError catch (e, st) {
return left(Failure(e.message, st));
} catch (e, st) {
return left(Failure(e.toString(), st));
}
}
@override
Future<List<MealModel>> getUserMealsWithinDates(
DateTime startDate, DateTime endDate) async {
final docList = await _db
.collection<MealModel>()
.filter()
.startTimeGreaterThan(startDate, include: true)
.startTimeLessThan(endDate.add(const Duration(days: 1)))
.findAll();
return docList;
}
}
As you can see this is a standard implementation of Isar CRUD methods. Two get meals within dates two methods are used - GreaterThan
and LessThan
. The include parameter makes the query inclusive.
It is also important to initiate the schema in the main file of the app:
final isar = await Isar.open(
[DogModelSchema, WalkModelSchema, MealModelSchema, VisitModelSchema],
directory: appDir.path);
All of the models I created in this post are initiated above.
Remote API
The remote API implements the same methods, but it will talk with the Appwrite backend.
final remoteMealAPIProvider = Provider((ref) {
final db = ref.watch(appwriteDbProvider);
return RemoteMealAPI(db: db);
});
abstract class IRemoteMealAPI {
FutureEitherVoid saveMealData(MealModel mealModel);
Future<List<MealModel?>> getUserMeals(String uid);
FutureEitherVoid updateMealData(MealModel mealModel);
FutureEitherVoid deleteMealData(MealModel mealModel);
Future<List<MealModel>> getUserMealsWithinDates(
String uid, DateTime startDate, DateTime endDate);
}
class RemoteMealAPI implements IRemoteMealAPI {
final Databases _db;
RemoteMealAPI({
required Databases db,
}) : _db = db;
@override
FutureEitherVoid deleteMealData(MealModel mealModel) async {
try {
await _db.deleteDocument(
databaseId: AppwriteConstants.databaseId,
collectionId: AppwriteConstants.mealsCollection,
documentId: mealModel.mid!,
);
return right(null);
} on AppwriteException catch (e, st) {
return left(Failure(
e.message ?? 'Some unexpected error',
st,
));
} catch (e, st) {
return left(Failure(
e.toString(),
st,
));
}
}
@override
Future<List<MealModel?>> getUserMeals(String uid) async {
final docList = await _db.listDocuments(
databaseId: AppwriteConstants.databaseId,
collectionId: AppwriteConstants.mealsCollection,
queries: [Query.equal('uid', uid)]);
List<MealModel> mealsList =
docList.documents.map((e) => MealModel.fromMap(e.data)).toList();
return mealsList;
}
@override
FutureEitherVoid saveMealData(MealModel mealModel) async {
try {
await _db.createDocument(
databaseId: AppwriteConstants.databaseId,
collectionId: AppwriteConstants.mealsCollection,
documentId: ID.unique(),
data: mealModel.toMap(),
);
return right(null);
} on AppwriteException catch (e, st) {
return left(Failure(e.message ?? 'Some unexpected error', st));
} catch (e, st) {
return left(Failure(e.toString(), st));
}
}
@override
FutureEitherVoid updateMealData(MealModel mealModel) async {
try {
await _db.updateDocument(
databaseId: AppwriteConstants.databaseId,
collectionId: AppwriteConstants.mealsCollection,
documentId: mealModel.mid!,
data: mealModel.toMap(),
);
return right(null);
} on AppwriteException catch (e, st) {
return left(Failure(e.message ?? 'Some unexpected error', st));
} catch (e, st) {
return left(Failure(e.toString(), st));
}
}
@override
Future<List<MealModel>> getUserMealsWithinDates(
String uid, DateTime startDate, DateTime endDate) async {
final docList = await _db.listDocuments(
databaseId: AppwriteConstants.databaseId,
collectionId: AppwriteConstants.mealsCollection,
queries: [
Query.equal('uid', uid),
Query.greaterThanEqual('startTime', startDate),
Query.lessThan('startTime', endDate.add(const Duration(days: 1))),
]);
List<MealModel> mealsList =
docList.documents.map((e) => MealModel.fromMap(e.data)).toList();
return mealsList;
}
}
Pretty standard implementation.
Application layer
The application and presentation layers sections will be separated for the events features and calendar features. The implementation for meals, visits, and walks is pretty similar so I will show only the code for the walks because it has some small differences caused by the possibility of sharing one walk with multiple dogs.
Event features
Walks service just decides which API should be used based on authorization status. It needs to have access to the local and remote meal APIs and the current user. This is the implementation of the whole service:
final walksServiceProvider = Provider<WalksService>((ref) {
return WalksService(
localWalkAPI: ref.watch(localWalkAPIIProvider),
remoteWalkAPI: ref.watch(remoteWalkAPIProvider),
currentUser: ref.watch(currentUserProvider).value);
});
class WalksService {
final LocalWalkAPI _localWalkAPI;
final RemoteWalkAPI _remoteWalkAPI;
final User? _currentUser;
final LocalDogAPI _localDogAPI;
final RemoteDogAPI _remoteDogAPI;
WalksService(
{required LocalWalkAPI localWalkAPI,
required RemoteWalkAPI remoteWalkAPI,
required User? currentUser})
: _localWalkAPI = localWalkAPI,
_remoteWalkAPI = remoteWalkAPI,
_currentUser = currentUser;
Future<List<WalkModel?>> getUserWalks() async {
if (_currentUser != null) {
final walksList = await _remoteWalkAPI.getUserWalks(_currentUser!.$id);
return walksList;
} else {
final walksList = await _localWalkAPI.getUserWalks();
return walksList;
}
}
FutureEitherVoid saveWalkData(WalkModel walkModel) async {
if (_currentUser != null) {
return await _remoteWalkAPI.saveWalkData(walkModel);
} else {
return await _localWalkAPI.saveWalkData(walkModel);
}
}
FutureEitherVoid updateWalkData(WalkModel walkModel) async {
if (_currentUser != null) {
return await _remoteWalkAPI.updateWalkData(walkModel);
} else {
return await _localWalkAPI.updateWalkData(walkModel);
}
}
FutureEitherVoid deleteWalkData(WalkModel walkModel) async {
if (_currentUser != null) {
return await _remoteWalkAPI.deleteWalkData(walkModel);
} else {
return await _localWalkAPI.deleteWalkData(walkModel);
}
}
}
Calendar feature
The calendar service is responsible for the widget-connected methods - finding the events between timestamps. It is also responsible for searching for dogs by ID, so it needs also access to the local and remote dog APIs because the models store only dog IDs and the user interface will display the dog name. This method is placed in the calendar feature because every event displayed in this view will use it.
final calendarServiceProvider = Provider<CalendarService>((ref) {
return CalendarService(
localWalkAPI: ref.watch(localWalkAPIIProvider),
remoteWalkAPI: ref.watch(remoteWalkAPIProvider),
localMealAPI: ref.watch(localMealAPIProvider),
remoteMealAPI: ref.watch(remoteMealAPIProvider),
localVisitAPI: ref.watch(localVisitAPIProvider),
remoteVisitAPI: ref.watch(remoteVisitAPIProvider),
localDogAPI: ref.watch(localDogAPIProvider),
remoteDogAPI: ref.watch(remoteDogAPIProvider),
currentUser: ref.watch(currentUserProvider).value,
);
});
class CalendarService {
final LocalWalkAPI _localWalkAPI;
final RemoteWalkAPI _remoteWalkAPI;
final LocalMealAPI _localMealAPI;
final RemoteMealAPI _remoteMealAPI;
final LocalVisitAPI _localVisitAPI;
final RemoteVisitAPI _remoteVisitAPI;
final User? _currentUser;
final LocalDogAPI _localDogAPI;
final RemoteDogAPI _remoteDogAPI;
CalendarService(
{required LocalWalkAPI localWalkAPI,
required RemoteWalkAPI remoteWalkAPI,
required LocalMealAPI localMealAPI,
required RemoteMealAPI remoteMealAPI,
required User? currentUser,
required LocalVisitAPI localVisitAPI,
required RemoteVisitAPI remoteVisitAPI,
required LocalDogAPI localDogAPI,
required RemoteDogAPI remoteDogAPI})
: _localWalkAPI = localWalkAPI,
_remoteWalkAPI = remoteWalkAPI,
_localMealAPI = localMealAPI,
_remoteMealAPI = remoteMealAPI,
_currentUser = currentUser,
_localVisitAPI = localVisitAPI,
_remoteVisitAPI = remoteVisitAPI,
_localDogAPI = localDogAPI,
_remoteDogAPI = remoteDogAPI;
Future<List<WalkModel>> getUserWalksWithinDates(
DateTime startTime, DateTime endTime) async {
if (_currentUser != null) {
final walksList = await _remoteWalkAPI.getUserWalksWithinDates(
_currentUser!.$id, startTime, endTime);
return walksList;
} else {
final walksList =
await _localWalkAPI.getUserWalksWithinDates(startTime, endTime);
return walksList;
}
}
Future<List<MealModel>> getUserMealsWithinDates(
DateTime startTime, DateTime endTime) async {
if (_currentUser != null) {
final mealsList = await _remoteMealAPI.getUserMealsWithinDates(
_currentUser!.$id, startTime, endTime);
return mealsList;
} else {
final mealsList =
await _localMealAPI.getUserMealsWithinDates(startTime, endTime);
return mealsList;
}
}
Future<List<VisitModel>> getUserVisitsWithinDates(
DateTime startTime, DateTime endTime) async {
if (_currentUser != null) {
final visitsList = await _remoteVisitAPI.getUserVisitsWithinDates(
_currentUser!.$id, startTime, endTime);
return visitsList;
} else {
final visitsLists =
await _localVisitAPI.getUserVisitsWithinDates(startTime, endTime);
return visitsLists;
}
}
Future<DogModel?> getDogById(id) async {
if (_currentUser != null) {
return await _remoteDogAPI.getDogById(id);
} else {
return await _localDogAPI.getDogById(id);
}
}
}
Presentation layer
The presentation layer for every event is quite similar. It should be able to display and manage the bottom sheet with a form to fill in by the user. The UI also needs a tile with an icon and text, which will display a dialog with complete information on click and should also have an icon that will display an edit form. The calendar feature will implement the view with three main widgets: a calendar, a bottom sheet with events icons to choose what to add, and a list of events.
Event features
Controller
The controller manages the state of the widget, for example, if the request to the back is in progress or already ended. The implementation is analogous to the controllers created in the previous posts. It mostly informs about the state of the methods that will be used in the widgets that need loaders and displays the snack bar with messages on errors.
final walksControllerProvider =
StateNotifierProvider<WalksController, bool>((ref) {
return WalksController(walksService: ref.watch(walksServiceProvider));
});
class WalksController extends StateNotifier<bool> {
final WalksService _walksService;
WalksController({required WalksService walksService})
: _walksService = walksService,
super(false);
Future<List<WalkModel?>> getUserWalks() async {
final res = await _walksService.getUserWalks();
return res;
}
void saveWalk({
required WalkModel walkModel,
required BuildContext context,
}) async {
state = true;
final res = await _walksService.saveWalkData(walkModel);
state = false;
res.fold(
(l) => showSnackBar(context, l.message), (r) => Navigator.pop(context));
}
void updateWalk({
required WalkModel walkModel,
required BuildContext context,
}) async {
state = true;
final res = await _walksService.updateWalkData(walkModel);
state = false;
res.fold(
(l) => showSnackBar(context, l.message), (r) => Navigator.pop(context));
}
void deleteWalk(
{required WalkModel walkModel, required BuildContext context}) async {
state = true;
final res = await _walksService.deleteWalkData(walkModel);
state = false;
res.fold(
(l) => showSnackBar(context, l.message), (r) => Navigator.pop(context));
}
}
Widgets
The first widget is the DogNameTextField
. It is responsible for displaying a text field with a dog name from the dogProviderFamily
if it has data, otherwise a loader or a message on error or if the the provider returned null for some reason. It is a consumer widget so only the text will be rebuilt when the user changes the name of the dog for example. All of the event features will use this widget, so I store it in the commons directory.
class DogNameTextField extends ConsumerWidget {
final dynamic dogId;
const DogNameTextField({super.key, required this.dogId});
@override
Widget build(BuildContext context, WidgetRef ref) {
return ref.watch(dogProviderFamily(dogId)).when(
data: (data) {
return data != null
? Text(
data.name,
)
: const Text("Something went wrong 😔");
},
error: (error, stackTrace) => const Text('Something went wrong 😔'),
loading: () => const Loader(),
);
}
}
The next one is a stateless widget that will build a row with a comma-separated list of dog names.
class DogsListTextField extends StatelessWidget {
final List<dynamic> dogsIds;
const DogsListTextField({Key? key, required this.dogsIds}) : super(key: key);
@override
Widget build(BuildContext context) {
final dogNames = dogsIds
.map((dogId) => DogNameTextField(dogId: dogId))
.expand((element) => [element, const Text(', ')])
.toList();
dogNames.removeLast();
return Row(
children: [const Text('Dogs: '), ...dogNames],
);
}
}
The removelast
method is used to get rid of the comma sign at the end of the string. Now let’s use it in the dialog displaying information about an event.
class WalkDialog extends StatelessWidget {
final WalkModel walk;
const WalkDialog({Key? key, required this.walk}) : super(key: key);
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text("Walk"),
content: IntrinsicHeight(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Start: ${DateFormat('hh:mm').format(walk.startTime)}',
style: const TextStyle(
fontSize: 14,
),
),
Text(
'End: ${DateFormat('hh:mm').format(walk.endTime)}',
style: const TextStyle(
fontSize: 14,
),
),
Text(
'Time: ${walk.endTime.difference(walk.startTime).inMinutes} minutes',
style: const TextStyle(
fontSize: 14,
),
),
Text(
'Steps: ${walk.steps}',
style: const TextStyle(
fontSize: 14,
),
),
Text(
'Distance: ${(walk.steps * 0.762).toStringAsFixed(2)} meters',
style: const TextStyle(
fontSize: 14,
),
),
DogsListTextField(dogsIds: walk.dogsIds)
],
),
),
);
}
}
The equation for calculating the distance from the steps is from the first result in Google, so I do not have any idea if it is legit. It looks like this in the app:
The next required widget is a dropdown picker with the possibility to choose multiple dogs at once.
final selectedDogsProvider =
StateProvider.autoDispose<List<DogModel>>((ref) => []);
class DogsPickerField extends ConsumerStatefulWidget {
final List<DogModel?> initialValue;
const DogsPickerField({super.key, required this.initialValue});
@override
ConsumerState<DogsPickerField> createState() => _DogsPickerFieldState();
}
class _DogsPickerFieldState extends ConsumerState<DogsPickerField> {
@override
Widget build(BuildContext context) {
final items = ref.watch(currentUserDogsProvider).valueOrNull;
List<dynamic> selectedItems = widget.initialValue;
final textStyle =
Theme.of(context).textTheme.bodyMedium!.copyWith(fontSize: 16);
return DropdownButtonFormField2(
decoration: InputDecoration(
isDense: true,
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(5),
borderSide: const BorderSide(
color: Colors.black87,
width: 3,
)),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(5),
borderSide: const BorderSide(
color: Colors.black54,
width: 3,
)),
contentPadding: const EdgeInsets.all(22),
),
buttonStyleData: const ButtonStyleData(width: 22, height: 22),
items: items == null
? []
: items.map((item) {
return DropdownMenuItem(
value: item,
enabled: false,
child: StatefulBuilder(
builder: (context, menuSetState) {
final isSelected = selectedItems.contains(item);
return InkWell(
onTap: () {
isSelected
? selectedItems.remove(item)
: selectedItems.add(item);
setState(() {});
menuSetState(() {});
},
child: Container(
height: double.infinity,
padding:
const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
isSelected
? const Icon(Icons.check_box_outlined)
: const Icon(
Icons.check_box_outline_blank),
const SizedBox(
width: 16,
),
Text(
item!.name,
style: textStyle,
)
],
)));
},
));
}).toList(),
hint: const Text('Dogs'),
value: selectedItems.isEmpty ? null : selectedItems.last,
onChanged: (newValue) {
ref.read(selectedDogsProvider.notifier).state.add(newValue as DogModel);
},
selectedItemBuilder: (context) {
return items == null
? []
: items.map(
(item) {
return Container(
alignment: AlignmentDirectional.center,
child: Text(
selectedItems.map((item) => item.name).join(', '),
maxLines: 1,
style: textStyle,
),
);
},
).toList();
},
);
}
}
It uses dropdown_button2 widget from the pub.dev which makes it quite easier. It has the auto disposable provider because the app does not need to remember the chosen values if the user is not seeing the widget anymore.
The last required widget is a simple tile that will be displayed under the calendar.
class WalkTile extends StatelessWidget {
final WalkModel walk;
const WalkTile({super.key, required this.walk});
@override
Widget build(BuildContext context) {
return ListTile(
leading: const FaIcon(FontAwesomeIcons.paw),
title: Text('Walk ${DateFormat('hh:mm').format(walk.startTime)}'),
trailing: IconButton(
onPressed: () {
showModalBottomSheet(
isScrollControlled: true,
context: context,
builder: (context) {
return EditWalkView(
walkModel: walk,
);
});
},
icon: const FaIcon(FontAwesomeIcons.penToSquare)),
onTap: () {
showDialog(
context: context,
builder: (BuildContext context) {
return WalkDialog(walk: walk);
});
},
);
}
}
It shall look like this:
View
Here two views - add and edit forms are needed. The implementation requires a separate controller for every text and data field. It also uses the drop-down widget created previously. It also checks if all form fields are filled. The steps field does not need to be filled, it will take 0 by default.
class AddWalkView extends ConsumerStatefulWidget {
const AddWalkView({super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() => _AddWalkViewState();
}
class _AddWalkViewState extends ConsumerState<AddWalkView> {
final startDateController = TextEditingController();
final endDateController = TextEditingController();
final stepsController = TextEditingController();
@override
void dispose() {
super.dispose();
stepsController.dispose();
startDateController.dispose();
endDateController.dispose();
}
bool _areAllFieldsFilled() {
final startDateText = startDateController.text;
final endDateText = endDateController.text;
return startDateText.isNotEmpty && endDateText.isNotEmpty;
}
@override
Widget build(BuildContext context) {
final user = ref.watch(currentUserProvider).value;
final selectedDogs = ref.watch(selectedDogsProvider);
return SingleChildScrollView(
padding: MediaQuery.of(context).viewInsets,
child: Column(children: [
const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
"Add a walk",
style: TextStyle(
fontSize: 20,
),
),
],
),
const FractionallySizedBox(
widthFactor: 0.5,
child: Image(
image: AssetImage('assets/images/walk_image.png'),
),
),
),
const SizedBox(
height: 5,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: DateTimePickerField(
controller: startDateController,
hintText: 'Start',
),
),
const SizedBox(
height: 5,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: DateTimePickerField(
controller: endDateController, hintText: 'End'),
),
const SizedBox(
height: 5,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: NumbersFormField(
controller: stepsController,
hintText: "Steps",
),
),
const SizedBox(
height: 5,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: DogsPickerField(initialValue: selectedDogs)),
const SizedBox(
height: 20,
),
RoundedTinyButton(
onTap: () {
if (_areAllFieldsFilled() && selectedDogs.isNotEmpty) {
ref.read(walksControllerProvider.notifier).saveWalk(
walkModel: WalkModel(
startTime: DateFormat('dd MMMM yyyy hh:mm')
.parse(startDateController.text),
uid: user?.$id,
steps: int.parse(stepsController.text.isNotEmpty
? stepsController.text
: '0'),
endTime: DateFormat('dd MMMM yyyy hh:mm')
.parse(endDateController.text),
dogsIds: selectedDogs
.map((e) => e.did ?? e.id.toString())
.toList()),
context: context,
);
} else {
showDialog(
context: context,
builder: (context) {
return const FormDialog();
},
);
}
},
label: 'Save',
),
const SizedBox(
height: 20,
)
]),
);
}
}
class EditWalkView extends ConsumerStatefulWidget {
final WalkModel walkModel;
const EditWalkView({super.key, required this.walkModel});
@override
ConsumerState<ConsumerStatefulWidget> createState() => _EditWalkViewState();
}
class _EditWalkViewState extends ConsumerState<EditWalkView> {
late TextEditingController startDateController;
late TextEditingController endDateController;
late TextEditingController stepsController;
@override
void initState() {
super.initState();
startDateController = TextEditingController(
text: DateFormat('dd MMMM yyyy hh:mm')
.format(widget.walkModel.startTime));
endDateController = TextEditingController(
text:
DateFormat('dd MMMM yyyy hh:mm').format(widget.walkModel.endTime));
stepsController =
TextEditingController(text: widget.walkModel.steps.toString());
}
@override
void dispose() {
super.dispose();
stepsController.dispose();
startDateController.dispose();
endDateController.dispose();
}
bool _areAllFieldsFilled() {
final startDateText = startDateController.text;
final endDateText = endDateController.text;
return startDateText.isNotEmpty && endDateText.isNotEmpty;
}
@override
Widget build(BuildContext context) {
final selectedDogs = ref.watch(selectedDogsProvider);
return SingleChildScrollView(
padding: MediaQuery.of(context).viewInsets,
child: Column(children: [
const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
"Edit a walk",
style: TextStyle(
fontSize: 20,
),
),
],
),
const FractionallySizedBox(
widthFactor: 0.5,
child: Image(
image: AssetImage('assets/images/walk_image.png'),
),
),
const SizedBox(
height: 5,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: DateTimePickerField(
controller: startDateController, hintText: 'Start'),
),
const SizedBox(
height: 5,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: DateTimePickerField(
controller: endDateController, hintText: 'End'),
),
const SizedBox(
height: 5,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: NumbersFormField(
controller: stepsController,
hintText: "Steps",
),
),
const SizedBox(
height: 5,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: DogsPickerField(initialValue: selectedDogs)),
const SizedBox(
height: 20,
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
RoundedTinyButton(
onTap: () {
if (_areAllFieldsFilled() && selectedDogs.isNotEmpty) {
ref.read(walksControllerProvider.notifier).updateWalk(
walkModel: widget.walkModel.copyWith(
startTime: DateFormat('dd MMMM yyyy hh:mm')
.parse(startDateController.text),
steps: int.parse(stepsController.text.isNotEmpty
? stepsController.text
: '0'),
endTime: DateFormat('dd MMMM yyyy hh:mm')
.parse(endDateController.text),
dogsIds: selectedDogs
.map((e) => e.did ?? e.id.toString())
.toList()),
context: context,
);
} else {
showDialog(
context: context,
builder: (context) {
return const FormDialog();
},
);
}
},
label: 'Save',
),
const SizedBox(
height: 20,
),
RoundedTinyButton(
onTap: () {
ref.read(walksControllerProvider.notifier).deleteWalk(
walkModel: widget.walkModel, context: context);
},
label: 'Delete')
],
),
const SizedBox(
height: 20,
)
]),
);
}
}
The forms look like this in the UI:
Calendar feature
Controller
The calendar controller is responsible for getting all events between dates and the dog’s entities by ID. As you can see those two methods don’t change any state, so the controller is maybe surplus, but I would like to have it in case of adding new features in the future. The asynchronous operations are not changing the state, because they both have the future providers, so it is possible to manage a state from the consumer widgets. The providers use families, so they generate a unique provider based on external parameters (for every unique dog and date).
final calendarControllerProvider =
StateNotifierProvider<CalendarController, bool>((ref) {
return CalendarController(
calendarService: ref.watch(calendarServiceProvider));
});
final currentDateEventsProviderFamily =
FutureProvider.family<List<dynamic>, DateTime>((ref, DateTime date) async {
ref.watch(walksControllerProvider);
ref.watch(mealsControllerProvider);
ref.watch(visitsControllerProvider);
return ref
.watch(calendarControllerProvider.notifier)
.getDateEvents(date: date);
});
final dogProviderFamily =
FutureProvider.family<DogModel?, dynamic>((ref, id) async {
ref.watch(yourDogsControllerProvider);
return ref.watch(calendarControllerProvider.notifier).getDogById(id: id);
});
class CalendarController extends StateNotifier<bool> {
final CalendarService _calendarService;
CalendarController({required CalendarService calendarService})
: _calendarService = calendarService,
super(false);
Future<List<dynamic>> getDateEvents({required DateTime date}) async {
final walks = await _calendarService.getUserWalksWithinDates(date, date);
final meals = await _calendarService.getUserMealsWithinDates(date, date);
final visits = await _calendarService.getUserVisitsWithinDates(date, date);
return [...walks, ...meals, ...visits];
}
Future<DogModel?> getDogById({required id}) async {
final res = await _calendarService.getDogById(id);
return res;
}
}
Widgets
The first widget is a simple bottom sheet that will let a user choose what event to add.
class EventsBottomSheet extends StatelessWidget {
const EventsBottomSheet({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: MediaQuery.of(context).viewInsets,
child: Column(
children: [
const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
"Add a new event",
style: TextStyle(fontSize: 20),
),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
ElevatedButton.icon(
label: const Text(
'Walk',
),
icon: const FaIcon(
FontAwesomeIcons.paw,
),
onPressed: () {
showModalBottomSheet(
isScrollControlled: true,
context: context,
builder: (context) {
return const AddWalkView();
});
},
),
ElevatedButton.icon(
label: const Text('Meal'),
icon: const FaIcon(
FontAwesomeIcons.bone,
),
onPressed: () {
showModalBottomSheet(
isScrollControlled: true,
context: context,
builder: (context) {
return const AddMealView();
});
},
),
ElevatedButton.icon(
label: const Text('Vet'),
icon: const FaIcon(
FontAwesomeIcons.kitMedical,
),
onPressed: () {
showModalBottomSheet(
isScrollControlled: true,
context: context,
builder: (context) {
return const AddVisitView();
});
},
),
],
),
const SizedBox(
height: 15,
)
],
),
);
}
}
And how it looks in the UI:
The next widget is an events list. It renders a tile with different icons based on the input type. The entries can be removed by swiping.
class EventsList extends ConsumerWidget {
final DateTime selectedDay;
const EventsList({super.key, required this.selectedDay});
@override
Widget build(BuildContext context, WidgetRef ref) {
return ref
.watch(currentDateEventsProviderFamily(
DateTime(selectedDay.year, selectedDay.month, selectedDay.day)))
.when(
data: (data) => data.isNotEmpty
? ListView.builder(
padding: const EdgeInsets.all(8),
itemCount: data.length,
itemBuilder: (context, index) {
final item = data[index];
if (item is WalkModel) {
return Dismissible(
key: Key(item.wid ?? item.id.toString()),
background: Container(color: Colors.amber),
onDismissed: (direction) {
ref
.read(walksControllerProvider.notifier)
.deleteWalk(
walkModel: item, context: context);
},
child: WalkTile(walk: item));
} else if (item is MealModel) {
return Dismissible(
key: Key(item.mid ?? item.id.toString()),
background: Container(color: Colors.amber),
onDismissed: (direction) {
ref
.read(mealsControllerProvider.notifier)
.deleteMeal(
mealModel: item, context: context);
},
child: MealTile(meal: item));
} else if (item is VisitModel) {
return Dismissible(
key: Key(item.vid ?? item.id.toString()),
background: Container(color: Colors.amber),
onDismissed: (direction) {
ref
.read(visitsControllerProvider.notifier)
.deleteVisit(
visitModel: item, context: context);
},
child: VisitTile(visit: item));
} else {
return const SizedBox();
}
},
)
: const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
height: 16,
),
Text(
"No events yet! 📅",
style: TextStyle(
fontSize: 16, fontWeight: FontWeight.bold),
),
SizedBox(
height: 8,
),
Text("You can add events using a button below"),
],
),
error: (er, stackTrace) => Text(er.toString()),
loading: () => const Loader());
}
}
It also renders helpful information if there are no events for the chosen date.
The last widget is the calendar itself. I am using table_calendar in the package, which makes creating this feature easy.
class MyCalendar extends ConsumerStatefulWidget {
const MyCalendar({super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() => _MyCalendarState();
}
class _MyCalendarState extends ConsumerState<MyCalendar> {
DateTime selectedDay = DateTime.now();
DateTime focusedDay = DateTime.now();
@override
void initState() {
super.initState();
}
List<dynamic> _getCurrentDateEvents(DateTime date) {
return ref
.watch(currentDateEventsProviderFamily(
DateTime(date.year, date.month, date.day)))
.when(
data: (data) {
return data;
},
error: (error, stackTrace) => [],
loading: () => []);
}
@override
Widget build(BuildContext context) {
return Column(
children: [
TableCalendar(
focusedDay: selectedDay,
calendarFormat: CalendarFormat.month,
firstDay: DateTime(1950),
lastDay: DateTime(2050),
daysOfWeekVisible: true,
onDaySelected: (DateTime selectDay, DateTime focusDay) {
setState(() {
selectedDay = selectDay;
focusedDay = focusDay;
});
},
eventLoader: _getCurrentDateEvents,
selectedDayPredicate: (DateTime date) {
return isSameDay(selectedDay, date);
},
calendarStyle: const CalendarStyle(
isTodayHighlighted: true,
todayDecoration: BoxDecoration(color: Colors.amberAccent),
selectedDecoration: BoxDecoration(color: Colors.amber),
),
headerStyle: const HeaderStyle(formatButtonVisible: false),
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [Text(DateFormat.yMMMMEEEEd().format(selectedDay))],
),
Expanded(child: EventsList(selectedDay: selectedDay))
],
);
}
}
As you can see the widget is wrapped by the ConsumerStatefulWidget. It lets me use the Riverpod syntax in the _getCurrentDateEvents
method. It watches for the changes in .watch(currentDateEventsProviderFamily
and if it has no data it returns an empty list. The widget also renders the selected date as a title and expanded events list (I know it could be also included in the view widget, but it was more convenient for me to place it here) under the calendar.
View
The view is super simple, it returns the stack with the calendar widget created previously and a floating action button in the right bottom corner. The FAB shows the events bottom sheet on click.
class CalendarView extends StatelessWidget {
const CalendarView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Stack(
children: [
const Center(
child: MyCalendar(),
),
Positioned(
bottom: 60,
right: 30,
child: MyFab(
iconData: const FaIcon(FontAwesomeIcons.plus),
onPressed: () {
showModalBottomSheet(
isScrollControlled: true,
context: context,
builder: (context) {
return const EventsBottomSheet();
});
},
))
],
);
}
}
The whole view is presented below:
Home
Home is the view visible after opening the app. It is just a scaffold with a navigation drawer created in the second post and the bottom navigation bar that is simply navigating via indexes of the page views in the list. The view also watches for changes in the current user, so if the authentication status changes the whole app rebuilds.
class HomeView extends ConsumerStatefulWidget {
static route() => MaterialPageRoute(builder: (context) => const HomeView());
const HomeView({super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() => _HomeViewState();
}
class _HomeViewState extends ConsumerState<HomeView> {
int _pageIndex = 0;
final pages = [
const YourDogsView(),
const ArticlesView(),
const CalendarView(),
];
void onPageChange(int index) {
setState(() {
_pageIndex = index;
});
}
@override
Widget build(BuildContext context) {
ref.watch(currentUserProvider);
return Scaffold(
appBar: UIConstants.titleAppBar(),
drawer: const MyNavigationDrawer(pages: [
{
'page': SettingsView(),
'icon': FaIcon(FontAwesomeIcons.gear),
'title': 'Settings'
},
]),
body: pages[_pageIndex],
bottomNavigationBar: NavigationBar(
selectedIndex: _pageIndex,
onDestinationSelected: onPageChange,
destinations: const [
NavigationDestination(
label: 'Your dogs',
icon: FaIcon(
FontAwesomeIcons.dog,
)),
NavigationDestination(
icon: FaIcon(FontAwesomeIcons.bookOpen),
label: 'Articles',
),
NavigationDestination(
label: 'Calendar',
icon: FaIcon(
FontAwesomeIcons.solidCalendar,
)),
]),
);
}
}
Now the app is complete. It can be built with the following command:
flutter build apk
Conclusion
The whole app is created. It is super simple, the code went spaghetti a bit, but it works as I planned, so I am quite fine with it. It could have a lot more features, but I do not think I will develop it more. The big front-end projects with a lot of dependencies are harder to plan, create, and maintain than I thought. The architectural design is very important here. Along with the development of the application, I’ve been getting a bit lost in my code. I think I will use Flutter as a main framework for the simple front end for my data-related projects. Understating the concepts of it is not hard, hot reload and multi-platform development are game changers, Dart is a pleasant programming language. I hope the series was at least a bit interesting. Cya in the next post!