Dogly - a mobile app with Flutter: your dogs

24 minute read

Introduction

In this post, I will create a Your Dogs view. It should display a floating action button for adding a dog. The “add a dog” form shall take from the user the following input:

  • photo (if not provided the default one),
  • name,
  • breed,
  • size - small, medium, large, or giant, choosable from the dropdown list,
  • date of birth - choosable from the calendar.

The information with the photo will be displayed on a card in the scrollable list (if the user has more dogs). The displayed age will be calculated using a formula from here. The card will also have an icon to edit information about a dog. Above the dogs’ list, I will implement a card that will display a random fact about dogs, using the Dog API (awesome stuff). The feature will also distinguish if the user is authenticated or not and decides to save the data to the local database or the remote one. I choose the Isar as a local database. The reason is very stupid, I just want to use something new instead of SQLite again. It also follows the document data model, so it is similar to the Appwrite database. Having a local database will ensure data persistence for non-authenticated users.

Let’s get to work!

Dog Model

A dog entity shall have the following attributes:

  • name
  • breed
  • size
  • date of birth
  • uid
  • did
  • id

The field requires a unique ID to identify it in queries etc. The application will use two APIs - a remote one when the user is authenticated and a local one if not. The Isar database gives an opportunity to automatically generate the integer key for every object of specified schema. For the remote entries, I will use also auto-generated document ID, which is a string, so the DogModel needs to have two nullable key fields, because I do not want to separate the models into local and remote. User ID is nullable because the non-authenticated user does not have any ID.

@collection
class DogModel {
  Id? id; // Isar id
  final String? did; // document id
  final String name;
  final String picture;
  final String? uid;
  final String breed;
  final String size;
  final DateTime dateOfBirth;

  DogModel({
    this.id,
    this.did,
    required this.name,
    required this.picture,
    required this.uid,
    required this.breed,
    required this.size,
    required this.dateOfBirth,
  });

The id field is indexed by default and the application is not making queries using any other field, so additional indexes are not required. Let’s also create copyWith method for updating entries and standard serialization methods.

DogModel copyWith({
    Id? id,
    String? did,
    String? name,
    String? picture,
    String? uid,
    String? breed,
    String? size,
    DateTime? dateOfBirth,
  }) {
    return DogModel(
      id: this.id,
      name: name ?? this.name,
      picture: picture ?? this.picture,
      did: did ?? this.did,
      uid: uid ?? this.uid,
      breed: breed ?? this.breed,
      size: size ?? this.size,
      dateOfBirth: dateOfBirth ?? this.dateOfBirth,
    );
  }

  Map<String, dynamic> toMap() {
    final result = <String, dynamic>{};
    result.addAll({'name': name});
    result.addAll({'picture': picture});
    result.addAll({'breed': breed});
    result.addAll({'size': size});
    result.addAll({'dateOfBirth': dateOfBirth.toIso8601String()});
    result.addAll({'uid': uid});
    return result;
  }

  factory DogModel.fromMap(Map<dynamic, dynamic> map) {
    return DogModel(
      name: map['name'] ?? '',
      picture: map['picture'] ?? '',
      did: map['\$id'] ?? '',
      uid: map['uid'] ?? '',
      breed: map['breed'] ?? '',
      size: map['size'] ?? '',
      dateOfBirth: DateTime.parse(map['dateOfBirth']),
    );
  }

Now I just need to run the code generator to create the Isar schema file for the @collection annotated class.

flutter pub run build_runner build

The dog_model.g.dart file should should be created in the domain directory.

Data layer

Constants

The first thing to do is create static constants with IDs of the dog collection and a dog bucket in the AppwriteConstants class. I also created a simple static method for accessing the file’s URLs quickly.

class AppwriteConstants {
// ... previous code
	static const String dogsCollection = "dogsCollectionId"
	static const String dogsBucket = "dogsBucketId"
	
  static String imageUrl(String bucketId, String imageId) =>
      '$endPoint/storage/buckets/$bucketId/files/$imageId/view?project=$projectId&mode=admin';
}

Remote DogsAPI

Remote DogsAPI will be responsible for talking with the Appwrite collection. Let’s create the interface with a bunch of useful methods. I know it could be one interface for local and remote APIs, but they have some different requirements. I would also use the DDD pattern and decide in the repository which data source should be used, but this decision will be made in the application layer. I am sure there is a better pattern for this situation and the code could be more scalable, but this is my first mobile app and I would like to try how it is going to work.

abstract class IRemoteDogApi {
  FutureEitherVoid saveDogData(DogModel dogModel);
  Future<List<DogModel?>> getUserDogs(String uid);
  FutureEitherVoid updateDogData(DogModel dogModel);
  FutureEitherVoid deleteDogData(DogModel dogModel);
  Future<DogModel> getDogById(String did);
}

As you can see there are five similar methods, in the CRUD similar manner. Let’s look a the implementation, The first method is the deleteDogData:

class RemoteDogAPI implements IRemoteDogApi {
  final Databases _db;

  RemoteDogAPI({
    required Databases db,
  }) : _db = db;

  @override
  FutureEitherVoid deleteDogData(DogModel dogModel) async {
    try {
      await _db.deleteDocument(
        databaseId: AppwriteConstants.databaseId,
        collectionId: AppwriteConstants.dogsCollection,
        documentId: dogModel.did!,
      );
      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,
      ));
    }
  }

Like a lot of other methods for communicating with the back end, it returns the future with a void or an error. It is important to tell the Dart that the document ID will be never null.

The methods for reading the dog’s data look like this:

  @override
  Future<List<DogModel?>> getUserDogs(String uid) async {
    final docList = await _db.listDocuments(
        databaseId: AppwriteConstants.databaseId,
        collectionId: AppwriteConstants.dogsCollection,
        queries: [Query.equal('uid', uid)]);

    List<DogModel> dogsList =
        docList.documents.map((e) => DogModel.fromMap(e.data)).toList();
    return dogsList;
  }
   @override
   Future<DogModel> getDogById(String did) async {
     final doc = await _db.getDocument(
       databaseId: AppwriteConstants.databaseId,
       collectionId: AppwriteConstants.dogsCollection,
       documentId: did,
     );

     return DogModel.fromMap(doc.data);
  }

The first one obtains a list with all of the user’s dogs using an “equal” query. It will be useful for populating the scrolling list on the main view of the app. The second method gets the specified dog using its document ID.

The last two methods are for creating and updating the records in the database’s collection:

 @override
  FutureEitherVoid saveDogData(DogModel dogModel) async {
    try {
      await _db.createDocument(
        databaseId: AppwriteConstants.databaseId,
        collectionId: AppwriteConstants.dogsCollection,
        documentId: ID.unique(),
        data: dogModel.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 updateDogData(DogModel dogModel) async {
    try {
      await _db.updateDocument(
        databaseId: AppwriteConstants.databaseId,
        collectionId: AppwriteConstants.dogsCollection,
        documentId: dogModel.did!,
        data: dogModel.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));
    }
  }

It is important to generate to unique ID for each document using unique method from the Appwrite.

The last thing to do here is create a Riverpod provider for the RemoteDLogsAPI.

final remoteDogAPIProvider = Provider((ref) {
  final db = ref.watch(appwriteDbProvider);
  return RemoteDogAPI(db: db);
});

Local DogsAPI

The local Isar database will be responsible for storing the data created by users without an account. It will persist in the device until the removal of the application or clearing of the application data. Let’s start with creating the provider for the Isar instance in the core providers file. The things here are quite different because the object is not immediately available. It is only available with the Future-based API, so it cannot be returned from the synchronous provider. The hack for this situation is initializing the provider and then overriding it before running the app when the instance is available. The advantage of this is the availability of the object in the whole app code without any Future related methods. The provider looks like this:

final isarInstanceProvider = Provider<Isar>((ref) {
  throw UnimplementedError();
});

Then it is overwritten in the main:

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final appDir = await getApplicationDocumentsDirectory();
  final isar = await Isar.open(
      [DogModelSchema],
      directory: appDir.path);

  runApp(
    ProviderScope(
      overrides: [
        appDirProvider.overrideWithValue(appDir),
        isarInstanceProvider.overrideWithValue(isar),
      ],
      child: const MyApp(),
    ),
  );
}

As you can see the Isar is initialized with the DogModelSchema created previously. I also initiate the Application Documents Directory with its provider in the same way. It is required for Isar and I will use it to store the photos locally.

The local interface has the same methods as the remote one, so here it is with the implementation already:

final localDogAPIProvider =
    Provider((ref) => LocalDogAPI(db: ref.watch(isarInstanceProvider)));
    
abstract class ILocalDogAPI {
  FutureEitherVoid saveDogData(DogModel dogModel);
  Future<List<DogModel?>> getUserDogs();
  FutureEitherVoid updateDogData(DogModel dogModel);
  FutureEitherVoid deleteDogData(DogModel dogModel);
  Future<DogModel?> getDogById(String key);
}

class LocalDogAPI implements ILocalDogAPI {
  final Isar _db;

  LocalDogAPI({required Isar db}) : _db = db;

  @override
  FutureEitherVoid deleteDogData(DogModel dogModel) async {
    try {
      await _db.writeTxn(() async {
        await _db.collection<DogModel>().delete(dogModel.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<DogModel?>> getUserDogs() async {
    final docList = await _db.collection<DogModel>().where().findAll();
    return docList;
  }

  @override
  FutureEitherVoid saveDogData(DogModel dogModel) async {
    try {
      await _db.writeTxn(() async {
        await _db.collection<DogModel>().put(dogModel);
      });

      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 updateDogData(DogModel dogModel) async {
    try {
      await _db.writeTxn(() async {
        await _db.collection<DogModel>().put(dogModel);
      });
      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<DogModel?> getDogById(String key) async {
    final dog = await _db.collection<DogModel>().get(int.parse(key));
    return dog;
  }
}

The only obvious difference is the lack of the userId parameter in the getUserDogs methods because it is an internal database and there is always only one user. The methods use standard CRUD Isar recipes and queries. Although the Isar auto-generated key is always an integer, the getDogById method requires a string. I parse it here because the remote methods need a string and I would like to keep them as similar as possible. I also do not need to remember to differentiate the types every time in the higher layers of the app.

Remote DogsStorageAPI

The RemoteDogsStorageAPI will be responsible for uploading the dog photos to the object bucket on the server. It works in the same way as the API for the user avatar storage created in the previous post.

abstract class IRemoteStorageAPI {
  FutureEither<String> uploadImage(File file);
}

class RemoteDogStorageAPI implements IRemoteStorageAPI {
  final Storage _storage;
  RemoteDogStorageAPI({required Storage storage}) : _storage = storage;

  @override
  FutureEither<String> uploadImage(File file) async {
    try {
      final uploadedImage = await _storage.createFile(
        bucketId: AppwriteConstants.dogsBucket,
        fileId: ID.unique(),
        file: InputFile.fromPath(path: file.path),
      );
      return right(AppwriteConstants.imageUrl(
          AppwriteConstants.dogsBucket, uploadedImage.$id));
    } on AppwriteException catch (e, st) {
      return left(Failure(e.message ?? 'Some unexpected error', st));
    } catch (e, st) {
      return left(Failure(e.toString(), st));
    }
  }
}

It returns the URL of the saved image because it needs to be included in the dogs collection entry also. The last thing to do is the provider.

final remoteDogStorageAPIProvider = Provider((ref) {
  return RemoteDogStorageAPI(
    storage: ref.watch(appwriteStorageProvider),
  );
});

Local DogsStorageAPI

The photos assigned without an account will be saved in the application documents directory. The uploadImage method first creates the path with the current timestamp, so it should never assign the two photos to the same path. Then it copies the file into this path.

abstract class ILocalStorageAPI {
  FutureEither<String> uploadImage(File file);
}

class LocalDogStorageAPI implements ILocalStorageAPI {
  final Directory _appDir;
  LocalDogStorageAPI({required Directory appDir}) : _appDir = appDir;

  @override
  FutureEither<String> uploadImage(File file) async {
    try {
      final filePath = '${_appDir.path}/image${DateTime.now()}.png';
      final targetFile = File(filePath);
      await file.copy(targetFile.path);
      return right(filePath);
    } catch (e, st) {
      return left(Failure(e.toString(), st));
    }
  }
}

The provider watches the appDirProvidercreated in the other paragraph.

final localDogStorageAPIProvider = Provider((ref) {
  return LocalDogStorageAPI(appDir: ref.watch(appDirProvider));
});

FactsAPI

The DogAPI is an awesome resource providing facts about dogs. To make the HTTP request I will use the library called Dio. It is quite powerful and makes HTTP networking easy.

final factsAPIProvider = Provider((ref) {
  return FactsAPI(dio: ref.watch(dioClientProvider));
});

abstract class IFactsAPI {
  Future<String> getFact();
}

class FactsAPI implements IFactsAPI {
  final Dio _dio;
  final _baseURL = 'https://dogapi.dog/api/v2/facts?limit=1';
  FactsAPI({required Dio dio}) : _dio = dio;

  @override
  Future<String> getFact() async {
    Response factData = await _dio.get(_baseURL);
    return (factData.data['data'][0]['attributes']['body'].toString());
  }

In the code above I just make a GET request with an URL that always returns one result. It is not random, to be honest, but the odds of fetching the same fact two times in a row are not so high, so I accept it. Then the getFact method returns a parsed result, so it instantly is the proper form for working correctly with the rest of the code base. Implementation of the dioClientProvider in the core is super simple:

final dioClientProvider = Provider<Dio>((ref) => Dio());

This is the whole data layer implementation (at least for now). Let’s move on to the application layer.

Application Layer

The application layer will take deciding to use a local API or a remote one. It requires access to every API I already created in this post and the currentUser value which also has a provider created in the previous post.

final dogsServiceProvider = Provider<DogsService>((ref) {
  return DogsService(
    localDogAPI: ref.watch(localDogAPIProvider),
    remoteDogAPI: ref.watch(remoteDogAPIProvider),
    currentUser: ref.watch(currentUserProvider).value,
    remoteStorageAPI: ref.watch(remoteDogStorageAPIProvider),
    localDogStorageAPI: ref.watch(localDogStorageAPIProvider),
    factsAPI: ref.watch(factsAPIProvider),
  );
});

class DogsService {
  final LocalDogAPI _localDogAPI;
  final RemoteDogAPI _remoteDogAPI;
  final User? _currentUser;
  final RemoteDogStorageAPI _remoteStorageAPI;
  final LocalDogStorageAPI _localDogStorageAPI;
  final FactsAPI _factsAPI;
  
  DogsService(
      {required LocalDogAPI localDogAPI,
      required RemoteDogAPI remoteDogAPI,
      required User? currentUser,
      required RemoteDogStorageAPI remoteStorageAPI,
      required LocalDogStorageAPI localDogStorageAPI,
      required FactsAPI factsAPI})
      : _localDogAPI = localDogAPI,
        _remoteDogAPI = remoteDogAPI,
        _currentUser = currentUser,
        _remoteStorageAPI = remoteStorageAPI,
        _localDogStorageAPI = localDogStorageAPI,
        _factsAPI = factsAPI;

Almost every function of the service checks if the current user is not null and decides to use the correct API. I won’t show everyone, they are similar. The FactAPI works regardless of authentication status, but I also implement it here just for clarity. It could be a good idea to store some facts in the device memory and show them when the user has no network connection, but I will maybe implement it in the future.

  Future<List<DogModel?>> getUserDogs() async {
    if (_currentUser != null) {
      final dogsList = await _remoteDogAPI.getUserDogs(_currentUser!.$id);
      return dogsList;
    } else {
      final dogsList = await _localDogAPI.getUserDogs();
      return dogsList;
    }
    
  FutureEither<String> uploadImage(io.File file) async {
    if (_currentUser != null) {
      return await _remoteStorageAPI.uploadImage(file);
    } else {
      return await _localDogStorageAPI.uploadImage(file);
    }
  }

  Future<String> getFact() async {
    return await _factsAPI.getFact();
  }

Presentation layer

Controller

The controller is responsible for managing the state of the related widgets. It will tell the widgets if the request to the database or storage is in progress, already ended successfully, or ended with an error (if so it will display the snack bar with the error message). Using AsyncValue.guard might be a better design for such a case, but I found out about it too late and it would ruin the whole idea of using fpdart 🙈. I will also show how to manage the execution of asynchronous methods in UI using the FutureProvider capabilities. I know it would be better to stick to a single pattern, but it is a learning field.

final yourDogsControllerProvider =
    StateNotifierProvider<YourDogsController, bool>((ref) {
  return YourDogsController(
    dogsService: ref.watch(dogsServiceProvider),
  );
});

final currentUserDogsProvider = FutureProvider((ref) async {
  ref.watch(yourDogsControllerProvider);
  final dogsController = ref.watch(yourDogsControllerProvider.notifier);
  return dogsController.getUserDogs();
});

final dogFactProvider = FutureProvider((ref) async {
  final factController = ref.watch(yourDogsControllerProvider.notifier);
  return factController.getFact();
});

final List<StateProvider<String>> idProviderList = [];

class YourDogsController extends StateNotifier<bool> {
  final DogsService _dogsService;

  YourDogsController({required DogsService dogsService})
      : _dogsService = dogsService,
        super(false);

  Future<String> getFact() async {
    final res = await _dogsService.getFact();
    return res;
  }

  Future<List<DogModel?>> getUserDogs() async {
    final res = await _dogsService.getUserDogs();
    return res;
  }

  void saveDog({
    required DogModel dogModel,
    required BuildContext context,
    required dart_io.File? profileFile,
  }) async {
    state = true;

    if (profileFile != null) {
      final imgUrl = await _dogsService.uploadImage(profileFile);
      imgUrl.fold((l) {
        showSnackBar(context, l.message);
      }, (r) {
        dogModel = dogModel.copyWith(picture: r);
      });
    } else {
      dogModel = dogModel.copyWith(picture: AssetsConstants.defaultDogPicture);
    }

    final res = await _dogsService.saveDogData(dogModel);
    state = false;

    res.fold(
        (l) => showSnackBar(context, l.message), (r) => Navigator.pop(context));
  }

  void updateDog({
    required DogModel dogModel,
    required BuildContext context,
    required dart_io.File? profileFile,
  }) async {
    state = true;

    if (profileFile != null) {
      final imgUrl = await _dogsService.uploadImage(profileFile);
      imgUrl.fold((l) {
        showSnackBar(context, l.message);
      }, (r) {
        dogModel = dogModel.copyWith(picture: r);
      });
    }

    final res = await _dogsService.updateDogData(dogModel);

    state = false;

    res.fold(
        (l) => showSnackBar(context, l.message), (r) => Navigator.pop(context));
  }

  void deleteDog({
    required DogModel dogModel,
    required BuildContext context,
  }) async {
    state = true;

    final res = await _dogsService.deleteDogData(dogModel);
    state = false;

    res.fold(
        (l) => showSnackBar(context, l.message), (r) => Navigator.pop(context));
  }
}

As you can see the controller requires just the dogsService instance, so the code in the methods is easy to read and manage. The create, update, and delete methods have the boolean state, where true indicates that the operations are in progress. The methods responsible for obtaining user’s dogs and facts don’t have a state, they both have a future provider instead of state notifier providers.

UI

Fact card

Let’s start from the top of the view. Fact cards will be always displayed there. It should contain the string with the card, a nice image of a curious dog, and an icon in the top right corner that will open a dialog with credentials for DogAPI and a URL to their website. There is a full widget code:

class FactCard extends ConsumerWidget {
  const FactCard({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Column(
      children: [
        Stack(
          children: [
            Card(
                child: Column(
              children: [
                Row(
                  children: [
                    const Image(
                      image: AssetImage('assets/images/fact_dog.png'),
                      height: 100,
                      width: 100,
                    ),
                    const SizedBox(
                      width: 8,
                    ),
                    Expanded(
                      child: Column(
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: [
                          const Text(
                            'Did you know? 🤔',
                            style: TextStyle(
                              fontSize: 16,
                              fontWeight: FontWeight.bold,
                            ),
                          ),
                          Text(
                              ref.watch(dogFactProvider).when(
                                  data: (fact) => fact,
                                  error: (e, st) =>
                                      "So sorry. Something went wrong at our end 😭",
                                  loading: () => "Loading..."),
                              style: const TextStyle(
                                fontSize: 14,
                                fontStyle: FontStyle.italic,
                              ))
                        ],
                      ),
                    )
                  ],
                )
              ],
            )),
            Positioned(
                top: -8,
                right: -4,
                child: IconButton(
                  onPressed: () {
                    showDialog(
                        context: context,
                        builder: (BuildContext context) {
                          return AlertDialog(
                              title:
                                  const Text('Powered by Stratonauts Dog API'),
                              content: RichText(
                                  text: TextSpan(children: [
                                const TextSpan(text: "Check it "),
                                TextSpan(
                                    text: "here!",
                                    style: const TextStyle(
                                        fontWeight: FontWeight.bold),
                                    recognizer: TapGestureRecognizer()
                                      ..onTap = () async {
                                        Uri url =
                                            Uri.parse("https://dogapi.dog/");
                                        if (await canLaunchUrl(url)) {
                                          await launchUrl(url,
                                              mode: LaunchMode
                                                  .externalApplication);
                                        } else {
                                          throw 'Could not launch $url';
                                        }
                                      })
                              ])));
                        });
                  },
                  icon: const FaIcon(FontAwesomeIcons.at, size: 14),
                ))
          ],
        ),
      ],
    );
  }
}

As you can see it uses a neat when method that lets me easily create the loading, error, and data states of the FutureProvider. On errors and loading, I inform the user with the proper messages, if data is obtained successfully the text is displayed. The credentials dialog uses a url_launcher plugin to open the URL if possible, otherwise throws the error. It is also important to add the proper intent to the AndroidManifest.xml to let the app open the URLs in the default device browser if it exist.

    <queries>
        <intent>
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data android:scheme="https" />
        </intent>
    </queries>

The fact card shall look like this in the app.

fact_cart

Dog card

A dog card is just a simple widget that displays a photo and basic information about a dog. It doesn’t even need to consume any Riverpod provider. It also calculates the age of the dog in the human years using the table from this article. There are functions. I also created the sizesHelper function to easily obtain the string to display in the UI from the database entry.

int calculateDateDifference(DateTime providedDate) {
  DateTime currentDate = DateTime.now();
  int difference = currentDate.difference(providedDate).inDays;
  return difference.abs();
}

String createDogAgeString(int dateDiff, String size) {
  if (dateDiff >= 365) {
    int diff = (dateDiff / 365).floor();
    Map<String, List<int>> sizeCategories = {
      "small": [15, 24, 28, 32, 36, 40, 44, 48, 52, 56, 60, 64, 68, 72, 76],
      "medium": [15, 24, 28, 32, 36, 42, 47, 51, 56, 60, 65, 69, 74, 78, 83],
      "large": [15, 24, 28, 32, 36, 45, 50, 55, 61, 66, 72, 77, 82, 88, 93],
      "giant": [12, 22, 31, 38, 45, 49, 56, 64, 71, 79, 86, 93, 100, 107, 114]
    };

    List<int> ageValues = sizeCategories[size]!;

    if (diff <= 15) {
      int humanAge = ageValues[diff - 1];
      return '$humanAge year(s) old';
    } else {
      int humanAge = ageValues.last +
          (ageValues.last - ageValues[ageValues.length - 2]) * (diff - 15);
      return '$humanAge year(s) old';
    }
  } else if (dateDiff >= 30) {
    int humanAge = (dateDiff / 30).floor();
    return '$humanAge month(s) old';
  } else {
    return '$dateDiff day(s) old';
  }
}

String? sizesHelper(String size) {
  Map<String, String> items = const {
    'small': 'Small < 10kg',
    'medium': 'Medium 10 - 22kg',
    'large': 'Large 22 - 45kg',
    'giant': 'Giant > 45kg',
  };
  return items[size];
}

The algorithm takes care if the dog is older than 15 years or younger than one year. To be honest, I do not know if it should be calculated like this, but it is just an example app. Let’s look the the widget code now.

class DogCard extends StatelessWidget {
  final DogModel dog;

  const DogCard({super.key, required this.dog});

  @override
  Widget build(BuildContext context) {
    return Stack(children: [
      Card(
        shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
        child: Column(
          children: [
            Row(
              children: [
                CircleAvatar(
                    radius: 50,
                    // backgroundColor: Colors.lightBlue,
                    child: Uri.parse(dog.picture).isAbsolute
                        ? CircleAvatar(
                            radius: 48,
                            backgroundImage: NetworkImage(dog.picture),
                          )
                        : CircleAvatar(
                            radius: 48,
                            backgroundImage: FileImage(File(dog.picture)),
                          )),
                const SizedBox(
                  width: 16,
                ),
                Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text(
                      'Name: ${dog.name}',
                      style: const TextStyle(
                        fontSize: 16,
                        fontWeight: FontWeight.bold,
                      ),
                    ),
                    Text(
                      'Breed: ${dog.breed}',
                      style: const TextStyle(
                        fontSize: 14,
                      ),
                    ),
                    Text(
                      'Age: ${createDogAgeString(calculateDateDifference(dog.dateOfBirth), dog.size)}',
                      style: const TextStyle(
                        fontSize: 14,
                      ),
                    ),
                  ],
                ),
              ],
            ),
          ],
        ),
      ),
      Positioned(
          top: 0,
          right: 0,
          child: IconButton(
            onPressed: () {
              showModalBottomSheet(
                  isScrollControlled: true,
                  context: context,
                  builder: (context) {
                    return EditDogView(
                      dogModel: dog,
                    );
                  });
            },
            icon: const FaIcon(
              FontAwesomeIcons.penToSquare,
              size: 18,
            ),
          ))
    ]);
  }
}

Using the Uri.parse method the code can check if the URL is absolute and decide to display the image from the server or local device storage. The card has also an edit icon in the right top corner that displays the bottom sheet with an EditDogView. It looks like this in the app:

dog_card

Dogs list

Pretty simple widget, it just displays a list of dog cards created previously for every dog provided by currentUserDogsProvider. If the list is empty it shows the image from assets with encouragement to add a dog.

class DogsList extends ConsumerWidget {
  const DogsList({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return ref.watch(currentUserDogsProvider).when(
        data: (dogs) {
          return dogs.isEmpty
              ? const Column(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: [
                    Image(
                      image: AssetImage('assets/images/add_dog.png'),
                      height: 120,
                    ),
                    Text(
                      "Add a first dog!",
                      style:
                          TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
                    ),
                    SizedBox(
                      height: 8,
                    ),
                    Text("Add your doggies using a button below"),
                  ],
                )
              : ListView.builder(
                  itemCount: dogs.length,
                  itemBuilder: (context, index) {
                    return DogCard(dog: dogs[index]!);
                  });
        },
        error: (error, stackTrace) => ErrorText(error: error.toString()),
        loading: () => const Loader());
  }
}

As you can see it also uses the when method. If the request is in progress, the loader is displayed. The empty list of dogs looks like this:

empty_list

Add a dog view

This view is a stateful widget because it needs to handle the text fields and the image picker. The chosen file is the state of the widget. The text fields have their TextEditingControllers as always. The widget has also a method to check if all of the text fields are filled. If not it shows a dialog asking the user to fix it. It uses the SingleChildScrollView with the MediaQuery.of(context).viewInsets, padding, to ensure the form can be scrolled if the container gets too small in one axis and the keyboard does not obscure it.

class AddDogView extends ConsumerStatefulWidget {
  const AddDogView({super.key});

  @override
  ConsumerState<ConsumerStatefulWidget> createState() => _AddDogViewState();
}

class _AddDogViewState extends ConsumerState<AddDogView> {
  final nameController = TextEditingController();
  final breedController = TextEditingController();
  final dateController = TextEditingController();
  File? dogFile;

  @override
  void dispose() {
    super.dispose();
    nameController.dispose();
    breedController.dispose();
    dateController.dispose();
  }

  void selectdogImage() async {
    final dogImage = await pickImage();
    if (dogImage != null) {
      setState(() {
        dogFile = dogImage;
      });
    }
  }

  bool _areAllFieldsFilled() {

    final nameText = nameController.text;
    final breedText = breedController.text;
    final dateText = dateController.text;

    return breedText.isNotEmpty && nameText.isNotEmpty && dateText.isNotEmpty;
  }

  @override
  Widget build(BuildContext context) {
    final user = ref.watch(currentUserProvider).value;
    final dogSize = ref.watch(selectedSizeProvider);

    return SingleChildScrollView(
      padding: MediaQuery.of(context).viewInsets,
      child: Column(children: [
        const Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(
              "Add a dog",
              style: TextStyle(
                fontSize: 16,
              ),
            ),
          ],
        ),
        Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            SizedBox(
              height: 120,
              child: Center(
                  child: Stack(
                children: [
                  GestureDetector(
                      onTap: selectdogImage,
                      child: dogFile != null
                          ? CircleAvatar(
                              backgroundImage: FileImage(dogFile!),
                              radius: 45,
                            )
                          : const CircleAvatar(
                              backgroundImage:
                                  AssetImage('assets/images/default_dog.png'),
                              radius: 45,
                            )),
                  const Positioned(
                      bottom: 2,
                      right: 8,
                      child: Icon(
                        Icons.edit_square,
                        size: 20,
  
                      ))
                ],
              )),
            ),
          ],
        ),
        Padding(
          padding: const EdgeInsets.symmetric(horizontal: 20),
          child: MyFormField(
            controller: nameController,
            hintText: "Name",
          ),
        ),
        const SizedBox(
          height: 5,
        ),
        Padding(
          padding: const EdgeInsets.symmetric(horizontal: 20),
          child: MyFormField(
            controller: breedController,
            hintText: "Breed",
          ),
        ),
        const SizedBox(
          height: 5,
        ),
        const Padding(
            padding: EdgeInsets.symmetric(horizontal: 20),
            child: DogSizePickerField()),
        const SizedBox(
          height: 5,
        ),
        Padding(
          padding: const EdgeInsets.symmetric(horizontal: 20),
          child: DatePickerField(
              controller: dateController, hintText: 'Date of birth'),
        ),
        const SizedBox(
          height: 20,
        ),
        RoundedTinyButton(
          onTap: () {
            if (_areAllFieldsFilled() && dogSize != null) {
              ref.read(yourDogsControllerProvider.notifier).saveDog(
                  dogModel: DogModel(
                      name: nameController.text,
                      breed: breedController.text,
                      size: dogSize.split(' ')[0].toLowerCase(),
                      dateOfBirth:
                          DateFormat('dd MMMM yyyy').parse(dateController.text),
                      picture: '',
                      uid: user?.$id),
                  context: context,
                  profileFile: dogFile);
            } else {
              showDialog(
                  context: context,
                  builder: (context) {
                    return const FormDialog();
                  });
            }
          },
          label: 'Save',

        ),
        const SizedBox(
          height: 20,
        )
      ]),
    );
  }
}

The MyFormField widget was created in the second post of the series, but there are two new fields - the DatePickerField and the DogSizePickerField. The first one is just a text field that shows the calendar on click and saves the selected date as text.

class DatePickerField extends StatelessWidget {
  final TextEditingController controller;
  final String hintText;

  const DatePickerField({
    super.key,
    required this.controller,
    required this.hintText,
  });

  @override
  Widget build(BuildContext context) {
    return TextFormField(
        controller: controller,
        readOnly: true,
        decoration: InputDecoration(
            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),
            hintText: hintText),
        onTap: () async {
          DateTime? pickedDate = await showDatePicker(
            context: context,
            initialDate: DateTime.now(),
            firstDate: DateTime(1950),
            lastDate: DateTime.now(),
          );
          if (pickedDate != null) {
            controller.text = DateFormat('dd MMMM yyyy').format(pickedDate);
          }
        });
  }
}

The showDatePicker function returns the future, so it needs to be asynchronous. It looks like this in the UI:

date_picker

The DogSizePickerField is a field with a dropdown list of dog sizes, so the user can choose it. It has its provider that keeps the selected value until it is out of view. This is a functionality of the autoDispose method from the Riverpod.

final selectedSizeProvider = StateProvider.autoDispose<String?>((ref) => null);

class DogSizePickerField extends ConsumerWidget {
  final String? initialValue;

  const DogSizePickerField({super.key, this.initialValue});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    List<String> items = const [
      'Small < 10kg',
      'Medium 10 - 22kg',
      'Large 22 - 45kg',
      'Giant > 45kg',
    ];
    final selectedItem = 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.map<DropdownMenuItem<String>>((String value) {
          return DropdownMenuItem<String>(
            value: value,
            child: Text(value, style: textStyle),
          );
        }).toList(),
        hint: const Text('Size'),
        value: selectedItem,
        onChanged: (newValue) =>
            ref.read(selectedSizeProvider.notifier).state = newValue!);
  }
}

That’s the reason why the widget watches for changes in the selectedSizeProvider.

size_picker

The whole bottom sheet looks like this:

add_a_dog

If every text field is filled and the user selects a dog size the widget fires the saveDog method from the controller. It also looks for the currentUserProvider value and saves it to the database if the user is logged in. If any of the fields is empty it shows the following dialog:

incomplete

Edit a dog view

To be honest, this view is almost the same as the previous one. It is copied and pasted with a few small changes. Let’s look at the code:

class EditDogView extends ConsumerStatefulWidget {
  final DogModel dogModel;
  const EditDogView({super.key, required this.dogModel});

  @override
  ConsumerState<ConsumerStatefulWidget> createState() => _EditDogViewState();
}

class _EditDogViewState extends ConsumerState<EditDogView> {
  late TextEditingController nameController;
  late TextEditingController breedController;
  late TextEditingController dateController;
  File? dogFile;

  @override
  void initState() {
    super.initState();
    nameController = TextEditingController(text: widget.dogModel.name);
    breedController = TextEditingController(text: widget.dogModel.breed);
    dateController = TextEditingController(
        text: DateFormat('dd MMMM yyyy').format(widget.dogModel.dateOfBirth));
  }

  @override
  void dispose() {
    super.dispose();
    nameController.dispose();
    breedController.dispose();
    dateController.dispose();
  }

  void selectdogImage() async {
    final dogImage = await pickImage();
    if (dogImage != null) {
      setState(() {
        dogFile = dogImage;
      });
    }
  }

  bool _areAllFieldsFilled() {
    final nameText = nameController.text;
    final breedText = breedController.text;
    final dateText = dateController.text;

    return breedText.isNotEmpty && nameText.isNotEmpty && dateText.isNotEmpty;
  }

  @override
  Widget build(BuildContext context) {
    final dogSize = ref.watch(selectedSizeProvider);

    return SingleChildScrollView(
      padding: MediaQuery.of(context).viewInsets,
      child: Column(children: [
        const Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(
              "Edit a dog",
              style: TextStyle(
                fontSize: 16,
              ),
            ),
          ],
        ),
        Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            SizedBox(
              height: 120,
              child: Center(
                  child: Stack(
                children: [
                  GestureDetector(
                      onTap: selectdogImage,
                      child: dogFile != null
                          ? CircleAvatar(
                              backgroundImage: FileImage(dogFile!),
                              radius: 45,
                            )
                          : Uri.parse(widget.dogModel.picture).isAbsolute
                              ? CircleAvatar(
                                  backgroundImage:
                                      NetworkImage(widget.dogModel.picture),
                                  radius: 45,
                                )
                              : CircleAvatar(
                                  backgroundImage:
                                      FileImage(File(widget.dogModel.picture)),
                                  radius: 45,
                                )),
                  const Positioned(
                      bottom: 2,
                      right: 8,
                      child: Icon(
                        Icons.edit_square,
                        size: 20,
                        // color: Colors.blue,
                      ))
                ],
              )),
            ),
          ],
        ),
        Padding(
          padding: const EdgeInsets.symmetric(horizontal: 20),
          child: MyFormField(
            controller: nameController,
            hintText: "Name",
          ),
        ),
        const SizedBox(
          height: 5,
        ),
        Padding(
          padding: const EdgeInsets.symmetric(horizontal: 20),
          child: MyFormField(
            controller: breedController,
            hintText: "Breed",
          ),
        ),
        const SizedBox(
          height: 5,
        ),
        Padding(
            padding: const EdgeInsets.symmetric(horizontal: 20),
            child: DogSizePickerField(
                initialValue: sizesHelper(widget.dogModel.size))),
        const SizedBox(
          height: 5,
        ),
        Padding(
          padding: const EdgeInsets.symmetric(horizontal: 20),
          child: DatePickerField(
            controller: dateController,
            hintText: "Date of birth",
          ),
        ),
        const SizedBox(
          height: 20,
        ),
        Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            RoundedTinyButton(
              onTap: () {
                if (_areAllFieldsFilled()) {
                  ref.read(yourDogsControllerProvider.notifier).updateDog(
                      dogModel: widget.dogModel.copyWith(
                          name: nameController.text,
                          breed: breedController.text,
                          size: dogSize != null
                              ? dogSize.split(' ')[0].toLowerCase()
                              : widget.dogModel.size,                          dateOfBirth: DateFormat('dd MMMM yyyy')
                              .parse(dateController.text)),
                      context: context,
                      profileFile: dogFile);
                } else {
                  showDialog(
                      context: context,
                      builder: (context) {
                        return const FormDialog();
                      });
                }
              },
              label: 'Save',
              // backgroundColor: Colors.blue,
              // textColor: Colors.white
            ),
            const SizedBox(width: 15),
            RoundedTinyButton(
              onTap: () {
                ref.read(yourDogsControllerProvider.notifier).deleteDog(
                      dogModel: widget.dogModel,
                      context: context,
                    );
              },
              label: 'Delete',
            ),
          ],
        ),
        const SizedBox(
          height: 20,
        )
      ]),
    );
  }
}

It just does not need to know if a user is authenticated, because the user ID field already exists in the edited model. It also needs to decide how the photo should be displayed, because now it is not always the default asset. It uses the already familiar Uri.parse method. It also does not need to check if the size of the dog is selected by the user, because it is already filled with the initial value, using the sizesHelper function. The bottom sheet has also the delete button which fires the deleteDog method from the controller, to remove the entry from the database and obviously from the list also.

edit_a_dog

Your dogs view

It is just a simple stateless widget that contains every layer created in this section.

class YourDogsView extends StatelessWidget {
  static route() =>
      MaterialPageRoute(builder: (context) => const YourDogsView());
  const YourDogsView({super.key});

  @override
  Widget build(BuildContext context) {
    return Stack(children: [
      const Column(
        children: [
          FactCard(),
          Expanded(child: DogsList()),
        ],
      ),
      Positioned(
          bottom: 60,
          right: 30,
          child: MyFab(
            iconData: const FaIcon(FontAwesomeIcons.plus),
            onPressed: () {
              showModalBottomSheet(
                  isScrollControlled: true,
                  context: context,
                  builder: (context) {
                    return const AddDogView();
                  });
            },
          ))
    ]);
  }
}

It displays a stack with a column with the FactCard on the top and then expanded DogsList. In the bottom right corner, it has the floating action button - MyFab that displays the bottom sheet with the AddDogView. The implementation of the fab is here:

class MyFab extends StatelessWidget {
  final Widget iconData;
  final VoidCallback onPressed;

  const MyFab({super.key, required this.iconData, required this.onPressed});

  @override
  Widget build(BuildContext context) {
    return FloatingActionButton(
      onPressed: onPressed,
      highlightElevation: 50,
      child: iconData,
    );
  }
}

It will be used in the calendar view also.

Voila! The whole screen is ready.

your_dogs

If you wonder about styling I use the basic amber color theme from the Material 3 design system.

Conclusion

This is probably the longest post I have ever created for my blog. I hope that someone made it to the end. Preparing a fairly simple view requires some tinkering to make everything look as the author envisioned. In the next post, I will show the settings view with the possibility to enable the dark mode, so it will be much shorter. Cya!

References

  1. https://resocoder.com/2020/03/09/flutter-firebase-ddd-course-1-domain-driven-design-principles/#t-1692638683768
  2. https://codewithandrea.com/articles/flutter-state-management-riverpod/
  3. https://codewithandrea.com/articles/riverpod-initialize-listener-app-startup/
  4. https://www.akc.org/expert-advice/health/how-to-calculate-dog-years-to-human-years/
  5. https://developer.android.com/training/package-visibility/use-cases