doc(arch): add fully featured example

This commit is contained in:
2022-11-08 20:09:18 -05:00
parent db67491876
commit 6602db215f
109 changed files with 4499 additions and 29 deletions
@@ -0,0 +1,25 @@
// Copyright (C) 2022 WYATT GROUP
// Please see the AUTHORS file for details.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import 'package:architecture_example/presentation/features/albums/state_management/albums_screen.dart';
import 'package:flutter/material.dart';
class Albums extends StatelessWidget {
const Albums({super.key});
@override
Widget build(BuildContext context) => const AlbumsScreen();
}
@@ -0,0 +1,94 @@
// Copyright (C) 2022 WYATT GROUP
// Please see the AUTHORS file for details.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import 'package:architecture_example/core/enums/fetch_status.dart';
import 'package:architecture_example/domain/entities/album.dart';
import 'package:architecture_example/domain/usecases/photos/params/query_parameters.dart';
import 'package:architecture_example/domain/usecases/photos/retrieve_all_albums.dart';
import 'package:bloc_concurrency/bloc_concurrency.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:stream_transform/stream_transform.dart';
part 'album_event.dart';
part 'album_state.dart';
const _albumLimit = 20;
const throttleDuration = Duration(milliseconds: 100);
EventTransformer<E> throttleDroppable<E>(Duration duration) =>
(events, mapper) => droppable<E>().call(events.throttle(duration), mapper);
class AlbumBloc extends Bloc<AlbumEvent, AlbumState> {
final RetrieveAllAlbums _retrieveAllAlbums;
AlbumBloc(this._retrieveAllAlbums) : super(const AlbumState()) {
on<AlbumFetched>(
_onAlbumFetched,
transformer: throttleDroppable(throttleDuration),
);
}
Future<void> _onAlbumFetched(
AlbumFetched event,
Emitter<AlbumState> emit,
) async {
if (state.hasReachedMax) {
return;
}
if (state.status == FetchStatus.initial) {
final albums =
await _retrieveAllAlbums.call(QueryParameters(0, _albumLimit));
return emit(
albums.fold(
(value) => state.copyWith(
status: FetchStatus.success,
albums: value,
hasReachedMax: false,
),
(error) => state.copyWith(
status: FetchStatus.failure,
albums: [],
hasReachedMax: false,
),
),
);
}
final albums = await _retrieveAllAlbums
.call(QueryParameters(state.albums.length, _albumLimit));
return emit(
albums.fold(
(value) {
if (value.isEmpty) {
return state.copyWith(
hasReachedMax: true,
);
}
return state.copyWith(
status: FetchStatus.success,
albums: List.of(state.albums)..addAll(value),
hasReachedMax: false,
);
},
(error) => state.copyWith(
status: FetchStatus.failure,
albums: List.of(state.albums),
hasReachedMax: false,
),
),
);
}
}
@@ -0,0 +1,26 @@
// Copyright (C) 2022 WYATT GROUP
// Please see the AUTHORS file for details.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
part of 'album_bloc.dart';
abstract class AlbumEvent extends Equatable {
const AlbumEvent();
@override
List<Object> get props => [];
}
class AlbumFetched extends AlbumEvent {}
@@ -0,0 +1,43 @@
// Copyright (C) 2022 WYATT GROUP
// Please see the AUTHORS file for details.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
part of 'album_bloc.dart';
class AlbumState extends Equatable {
const AlbumState({
this.status = FetchStatus.initial,
this.albums = const <Album>[],
this.hasReachedMax = false,
});
final FetchStatus status;
final List<Album> albums;
final bool hasReachedMax;
AlbumState copyWith({
FetchStatus? status,
List<Album>? albums,
bool? hasReachedMax,
}) =>
AlbumState(
status: status ?? this.status,
albums: albums ?? this.albums,
hasReachedMax: hasReachedMax ?? this.hasReachedMax,
);
@override
List<Object> get props => [status, albums, hasReachedMax];
}
@@ -0,0 +1,58 @@
// Copyright (C) 2022 WYATT GROUP
// Please see the AUTHORS file for details.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import 'package:architecture_example/core/enums/fetch_status.dart';
import 'package:architecture_example/domain/repositories/photo_repository.dart';
import 'package:architecture_example/domain/usecases/photos/retrieve_all_albums.dart';
import 'package:architecture_example/presentation/features/albums/blocs/album/album_bloc.dart';
import 'package:architecture_example/presentation/features/albums/state_management/albums_wrapper_widget.dart';
import 'package:architecture_example/presentation/features/albums/state_management/widgets/albums_list.dart';
import 'package:flutter/material.dart';
import 'package:wyatt_bloc_helper/wyatt_bloc_helper.dart';
class AlbumsScreen extends BlocScreen<AlbumBloc, AlbumEvent, AlbumState> {
const AlbumsScreen({super.key});
@override
AlbumBloc create(BuildContext context) =>
AlbumBloc(RetrieveAllAlbums(repo<PhotoRepository>(context)));
@override
AlbumBloc init(BuildContext context, AlbumBloc bloc) =>
bloc..add(AlbumFetched());
@override
Widget onWrap(BuildContext context, Widget child) => AlbumsWrapperWidget(
child: child,
);
@override
Widget onBuild(BuildContext context, AlbumState state) {
switch (state.status) {
case FetchStatus.initial:
return const Center(child: CircularProgressIndicator());
case FetchStatus.failure:
return const Center(
child: Text('failed to fetch albums'),
);
case FetchStatus.success:
return AlbumsList(
albums: state.albums,
hasReachedMax: state.hasReachedMax,
);
}
}
}
@@ -0,0 +1,31 @@
// Copyright (C) 2022 WYATT GROUP
// Please see the AUTHORS file for details.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import 'package:flutter/material.dart';
class AlbumsWrapperWidget extends StatelessWidget {
const AlbumsWrapperWidget({required this.child, super.key});
final Widget child;
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
title: const Text('Albums'),
),
body: child,
);
}
@@ -0,0 +1,77 @@
// Copyright (C) 2022 WYATT GROUP
// Please see the AUTHORS file for details.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import 'package:architecture_example/domain/entities/album.dart';
import 'package:architecture_example/presentation/features/albums/blocs/album/album_bloc.dart';
import 'package:architecture_example/presentation/features/albums/state_management/widgets/albums_list_item.dart';
import 'package:architecture_example/presentation/shared/widgets/bottom_loader.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class AlbumsList extends StatefulWidget {
const AlbumsList(
{required this.albums, required this.hasReachedMax, super.key,});
final List<Album> albums;
final bool hasReachedMax;
@override
State<AlbumsList> createState() => _AlbumsListState();
}
class _AlbumsListState extends State<AlbumsList> {
final _scrollController = ScrollController();
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
}
@override
void dispose() {
_scrollController
..removeListener(_onScroll)
..dispose();
super.dispose();
}
void _onScroll() {
if (_isBottom) {
context.read<AlbumBloc>().add(AlbumFetched());
}
}
bool get _isBottom {
if (!_scrollController.hasClients) {
return false;
}
final maxScroll = _scrollController.position.maxScrollExtent;
final currentScroll = _scrollController.offset;
return currentScroll >= (maxScroll * 0.9);
}
@override
Widget build(BuildContext context) => ListView.builder(
itemBuilder: (context, index) => index >= widget.albums.length
? const BottomLoader()
: AlbumsListItem(album: widget.albums[index]),
itemCount: widget.hasReachedMax
? widget.albums.length
: widget.albums.length + 1,
controller: _scrollController,
);
}
@@ -0,0 +1,37 @@
// Copyright (C) 2022 WYATT GROUP
// Please see the AUTHORS file for details.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import 'package:architecture_example/domain/entities/album.dart';
import 'package:architecture_example/presentation/features/photos/photos.dart';
import 'package:flutter/material.dart';
class AlbumsListItem extends StatelessWidget {
const AlbumsListItem({required this.album, super.key});
final Album album;
@override
Widget build(BuildContext context) => ListTile(
leading: Text('${album.id}'),
title: Text(album.title),
dense: true,
onTap: () => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (context) => Photos(albumId: album.id),
),
),
);
}
@@ -0,0 +1,47 @@
// Copyright (C) 2022 WYATT GROUP
// Please see the AUTHORS file for details.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import 'package:architecture_example/core/dependency_injection/get_it.dart';
import 'package:architecture_example/data/repositories/photo_repository_impl.dart';
import 'package:architecture_example/domain/data_sources/local/favorite_local_data_source.dart';
import 'package:architecture_example/domain/data_sources/remote/album_remote_data_source.dart';
import 'package:architecture_example/domain/data_sources/remote/photo_remote_data_source.dart';
import 'package:architecture_example/domain/repositories/photo_repository.dart';
import 'package:architecture_example/presentation/features/albums/albums.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class App extends StatelessWidget {
const App({super.key});
@override
Widget build(BuildContext context) => MultiRepositoryProvider(
providers: [
RepositoryProvider<PhotoRepository>(
create: (_) => PhotoRepositoryImpl(
getIt<PhotoRemoteDataSource>(),
getIt<AlbumRemoteDataSource>(),
getIt<FavoriteLocalDataSource>(),
),
),
],
child: const MaterialApp(
title: 'Demo',
debugShowCheckedModeBanner: false,
home: Albums(),
),
);
}
@@ -0,0 +1,79 @@
// ignore_for_file: public_member_api_docs, sort_constructors_first
// Copyright (C) 2022 WYATT GROUP
// Please see the AUTHORS file for details.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import 'dart:async';
import 'package:architecture_example/domain/entities/photo.dart';
import 'package:architecture_example/domain/usecases/photos/add_photo_to_favorites.dart';
import 'package:architecture_example/domain/usecases/photos/check_if_photo_is_in_favorites.dart';
import 'package:architecture_example/domain/usecases/photos/display_photo.dart';
import 'package:architecture_example/domain/usecases/photos/remove_photo_from_favorites.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
part 'photo_details_state.dart';
class PhotoDetailsCubit extends Cubit<PhotoDetailsState> {
final DisplayPhoto _displayPhoto;
final CheckIfPhotoIsInFavorites _checkIfPhotoIsInFavorites;
final AddPhotoToFavorites _addPhotoToFavorites;
final RemovePhotoFromFavorites _removePhotoFromFavorites;
PhotoDetailsCubit(
this._displayPhoto,
this._checkIfPhotoIsInFavorites,
this._addPhotoToFavorites,
this._removePhotoFromFavorites,
) : super(PhotoDetailsInitial());
FutureOr<void> load(int photoId) async {
final photo = await _displayPhoto.call(photoId);
final isFavorite = await _checkIfPhotoIsInFavorites.call(photoId);
emit(
photo.fold(
(value) => PhotoDetailsSuccess(
value,
isFavorite: isFavorite.fold((value) => value, (error) => false),
),
(error) => PhotoDetailsFailure(error.toString()),
),
);
}
FutureOr<void> addToFavorites() async {
if (state is! PhotoDetailsSuccess) {
return;
}
final actualState = state as PhotoDetailsSuccess;
final response = await _addPhotoToFavorites.call(actualState.photo);
if (response.isOk) {
emit(PhotoDetailsSuccess(actualState.photo, isFavorite: true));
}
}
FutureOr<void> removeFromFavorites() async {
if (state is! PhotoDetailsSuccess) {
return;
}
final actualState = state as PhotoDetailsSuccess;
final response = await _removePhotoFromFavorites.call(actualState.photo.id);
if (response.isOk) {
emit(PhotoDetailsSuccess(actualState.photo, isFavorite: false));
}
}
}
@@ -0,0 +1,45 @@
// Copyright (C) 2022 WYATT GROUP
// Please see the AUTHORS file for details.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
part of 'photo_details_cubit.dart';
abstract class PhotoDetailsState extends Equatable {
const PhotoDetailsState();
@override
List<Object> get props => [];
}
class PhotoDetailsInitial extends PhotoDetailsState {}
class PhotoDetailsSuccess extends PhotoDetailsState {
final Photo photo;
final bool isFavorite;
const PhotoDetailsSuccess(this.photo, {required this.isFavorite});
@override
List<Object> get props => [photo, isFavorite];
}
class PhotoDetailsFailure extends PhotoDetailsState {
final String error;
const PhotoDetailsFailure(this.error);
@override
List<Object> get props => [error];
}
@@ -0,0 +1,27 @@
// Copyright (C) 2022 WYATT GROUP
// Please see the AUTHORS file for details.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import 'package:architecture_example/presentation/features/photo_details/state_management/photo_details_screen.dart';
import 'package:flutter/material.dart';
class PhotoDetails extends StatelessWidget {
const PhotoDetails({required this.photoId, super.key});
final int photoId;
@override
Widget build(BuildContext context) => PhotoDetailsScreen(photoId: photoId);
}
@@ -0,0 +1,119 @@
// Copyright (C) 2022 WYATT GROUP
// Please see the AUTHORS file for details.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import 'package:architecture_example/domain/repositories/photo_repository.dart';
import 'package:architecture_example/domain/usecases/photos/add_photo_to_favorites.dart';
import 'package:architecture_example/domain/usecases/photos/check_if_photo_is_in_favorites.dart';
import 'package:architecture_example/domain/usecases/photos/display_photo.dart';
import 'package:architecture_example/domain/usecases/photos/remove_photo_from_favorites.dart';
import 'package:architecture_example/presentation/features/photo_details/blocs/photo_details/photo_details_cubit.dart';
import 'package:architecture_example/presentation/features/photo_details/state_management/photo_details_wrapper_widget.dart';
import 'package:flutter/material.dart';
import 'package:wyatt_bloc_helper/wyatt_bloc_helper.dart';
class PhotoDetailsScreen
extends CubitScreen<PhotoDetailsCubit, PhotoDetailsState> {
const PhotoDetailsScreen({required this.photoId, super.key});
final int photoId;
@override
PhotoDetailsCubit create(BuildContext context) => PhotoDetailsCubit(
DisplayPhoto(repo<PhotoRepository>(context)),
CheckIfPhotoIsInFavorites(repo<PhotoRepository>(context)),
AddPhotoToFavorites(repo<PhotoRepository>(context)),
RemovePhotoFromFavorites(repo<PhotoRepository>(context)),
);
@override
PhotoDetailsCubit init(BuildContext context, PhotoDetailsCubit bloc) =>
bloc..load(photoId);
@override
Widget onWrap(BuildContext context, Widget child) =>
PhotoDetailsWrapperWidget(child: child);
@override
Widget onBuild(BuildContext context, PhotoDetailsState state) {
if (state is PhotoDetailsFailure) {
return const Center(
child: Text('failed to fetch photo details'),
);
}
if (state is PhotoDetailsSuccess) {
return CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 400,
stretch: true,
flexibleSpace: FlexibleSpaceBar(
background: Stack(
children: [
Positioned.fill(
child: Image.network(
state.photo.thumbnailUrl,
fit: BoxFit.cover,
),
),
Positioned.fill(
child: Image.network(
state.photo.url,
fit: BoxFit.cover,
),
),
],
),
),
),
SliverList(
delegate: SliverChildListDelegate([
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: Text(
state.photo.title,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
IconButton(
onPressed: () {
state.isFavorite
? bloc(context).removeFromFavorites()
: bloc(context).addToFavorites();
},
icon: Icon(
state.isFavorite
? Icons.favorite
: Icons.favorite_outline,
),
),
],
),
),
]),
),
],
);
}
return const Center(
child: CircularProgressIndicator(),
);
}
}
@@ -0,0 +1,28 @@
// Copyright (C) 2022 WYATT GROUP
// Please see the AUTHORS file for details.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import 'package:flutter/material.dart';
class PhotoDetailsWrapperWidget extends StatelessWidget {
const PhotoDetailsWrapperWidget({required this.child, super.key});
final Widget child;
@override
Widget build(BuildContext context) => Scaffold(
body: child,
);
}
@@ -0,0 +1,40 @@
// Copyright (C) 2022 WYATT GROUP
// Please see the AUTHORS file for details.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import 'dart:async';
import 'package:architecture_example/domain/usecases/photos/check_if_photo_is_in_favorites.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
part 'favorite_checker_state.dart';
class FavoriteCheckerCubit extends Cubit<FavoriteCheckerState> {
final CheckIfPhotoIsInFavorites _checkIfPhotoIsInFavorites;
FavoriteCheckerCubit(this._checkIfPhotoIsInFavorites)
: super(FavoriteCheckerInitial());
FutureOr<void> checkIfPhotoIsInFavorites(int photoId) async {
final response = await _checkIfPhotoIsInFavorites.call(photoId);
emit(
response.fold(
(value) => FavoriteCheckerSuccess(photoId, isFavorite: value),
(error) => FavoriteCheckerFailure(error.toString()),
),
);
}
}
@@ -0,0 +1,29 @@
part of 'favorite_checker_cubit.dart';
abstract class FavoriteCheckerState extends Equatable {
const FavoriteCheckerState();
@override
List<Object> get props => [];
}
class FavoriteCheckerInitial extends FavoriteCheckerState {}
class FavoriteCheckerSuccess extends FavoriteCheckerState {
final int photoId;
final bool isFavorite;
const FavoriteCheckerSuccess(this.photoId, {required this.isFavorite});
@override
List<Object> get props => [photoId, isFavorite];
}
class FavoriteCheckerFailure extends FavoriteCheckerState {
final String error;
const FavoriteCheckerFailure(this.error);
@override
List<Object> get props => [error];
}
@@ -0,0 +1,104 @@
// Copyright (C) 2022 WYATT GROUP
// Please see the AUTHORS file for details.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import 'package:architecture_example/core/enums/fetch_status.dart';
import 'package:architecture_example/domain/entities/photo.dart';
import 'package:architecture_example/domain/usecases/photos/open_album.dart';
import 'package:architecture_example/domain/usecases/photos/params/query_parameters.dart';
import 'package:bloc_concurrency/bloc_concurrency.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:stream_transform/stream_transform.dart';
part 'photo_event.dart';
part 'photo_state.dart';
const _photoLimit = 40;
const throttleDuration = Duration(milliseconds: 100);
EventTransformer<E> throttleDroppable<E>(Duration duration) =>
(events, mapper) => droppable<E>().call(events.throttle(duration), mapper);
class PhotoBloc extends Bloc<PhotoEvent, PhotoState> {
final OpenAlbum _openAlbum;
PhotoBloc(this._openAlbum) : super(const PhotoState()) {
on<PhotoFetched>(
_onPhotoFetched,
transformer: throttleDroppable(throttleDuration),
);
}
Future<void> _onPhotoFetched(
PhotoFetched event,
Emitter<PhotoState> emit,
) async {
if (state.hasReachedMax) {
return;
}
if (state.status == FetchStatus.initial) {
final photos = await _openAlbum.call(
QueryParameters(
0,
_photoLimit,
albumId: event.albumId,
),
);
return emit(
photos.fold(
(value) => state.copyWith(
status: FetchStatus.success,
photos: value,
hasReachedMax: false,
),
(error) => state.copyWith(
status: FetchStatus.failure,
photos: [],
hasReachedMax: false,
),
),
);
}
final photos = await _openAlbum.call(
QueryParameters(
state.photos.length,
_photoLimit,
albumId: event.albumId,
),
);
return emit(
photos.fold(
(value) {
if (value.isEmpty) {
return state.copyWith(
hasReachedMax: true,
);
}
return state.copyWith(
status: FetchStatus.success,
photos: List.of(state.photos)..addAll(value),
hasReachedMax: false,
);
},
(error) => state.copyWith(
status: FetchStatus.failure,
photos: List.of(state.photos),
hasReachedMax: false,
),
),
);
}
}
@@ -0,0 +1,30 @@
// Copyright (C) 2022 WYATT GROUP
// Please see the AUTHORS file for details.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
part of 'photo_bloc.dart';
abstract class PhotoEvent extends Equatable {
const PhotoEvent();
@override
List<Object> get props => [];
}
class PhotoFetched extends PhotoEvent {
final int albumId;
const PhotoFetched(this.albumId);
}
@@ -0,0 +1,43 @@
// Copyright (C) 2022 WYATT GROUP
// Please see the AUTHORS file for details.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
part of 'photo_bloc.dart';
class PhotoState extends Equatable {
const PhotoState({
this.status = FetchStatus.initial,
this.photos = const <Photo>[],
this.hasReachedMax = false,
});
final FetchStatus status;
final List<Photo> photos;
final bool hasReachedMax;
PhotoState copyWith({
FetchStatus? status,
List<Photo>? photos,
bool? hasReachedMax,
}) =>
PhotoState(
status: status ?? this.status,
photos: photos ?? this.photos,
hasReachedMax: hasReachedMax ?? this.hasReachedMax,
);
@override
List<Object> get props => [status, photos, hasReachedMax];
}
@@ -0,0 +1,27 @@
// Copyright (C) 2022 WYATT GROUP
// Please see the AUTHORS file for details.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import 'package:architecture_example/presentation/features/photos/state_management/photos_screen.dart';
import 'package:flutter/material.dart';
class Photos extends StatelessWidget {
const Photos({required this.albumId, super.key});
final int albumId;
@override
Widget build(BuildContext context) => PhotosScreen(albumId: albumId);
}
@@ -0,0 +1,61 @@
// Copyright (C) 2022 WYATT GROUP
// Please see the AUTHORS file for details.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import 'package:architecture_example/core/enums/fetch_status.dart';
import 'package:architecture_example/domain/repositories/photo_repository.dart';
import 'package:architecture_example/domain/usecases/photos/open_album.dart';
import 'package:architecture_example/presentation/features/photos/blocs/photo/photo_bloc.dart';
import 'package:architecture_example/presentation/features/photos/state_management/photos_wrapper_widget.dart';
import 'package:architecture_example/presentation/features/photos/state_management/widgets/photos_grid.dart';
import 'package:flutter/material.dart';
import 'package:wyatt_bloc_helper/wyatt_bloc_helper.dart';
class PhotosScreen extends BlocScreen<PhotoBloc, PhotoEvent, PhotoState> {
const PhotosScreen({required this.albumId, super.key});
final int albumId;
@override
PhotoBloc create(BuildContext context) =>
PhotoBloc(OpenAlbum(repo<PhotoRepository>(context)));
@override
PhotoBloc init(BuildContext context, PhotoBloc bloc) =>
bloc..add(PhotoFetched(albumId));
@override
Widget onWrap(BuildContext context, Widget child) => PhotosWrapperWidget(
child: child,
);
@override
Widget onBuild(BuildContext context, PhotoState state) {
switch (state.status) {
case FetchStatus.initial:
return const Center(child: CircularProgressIndicator());
case FetchStatus.failure:
return const Center(
child: Text('failed to fetch photos'),
);
case FetchStatus.success:
return PhotosGrid(
photos: state.photos,
albumId: albumId,
hasReachedMax: state.hasReachedMax,
);
}
}
}
@@ -0,0 +1,31 @@
// Copyright (C) 2022 WYATT GROUP
// Please see the AUTHORS file for details.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import 'package:flutter/material.dart';
class PhotosWrapperWidget extends StatelessWidget {
const PhotosWrapperWidget({required this.child, super.key});
final Widget child;
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
title: const Text('Photos'),
),
body: child,
);
}
@@ -0,0 +1,87 @@
// Copyright (C) 2022 WYATT GROUP
// Please see the AUTHORS file for details.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import 'package:architecture_example/domain/entities/photo.dart';
import 'package:architecture_example/presentation/features/photos/blocs/photo/photo_bloc.dart';
import 'package:architecture_example/presentation/features/photos/state_management/widgets/photos_grid_thumbnail.dart';
import 'package:architecture_example/presentation/shared/widgets/bottom_loader.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class PhotosGrid extends StatefulWidget {
const PhotosGrid({
required this.photos,
required this.albumId,
required this.hasReachedMax,
super.key,
});
final List<Photo> photos;
final int albumId;
final bool hasReachedMax;
@override
State<PhotosGrid> createState() => _PhotosGridState();
}
class _PhotosGridState extends State<PhotosGrid> {
final _scrollController = ScrollController();
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
}
@override
void dispose() {
_scrollController
..removeListener(_onScroll)
..dispose();
super.dispose();
}
void _onScroll() {
if (_isBottom) {
context.read<PhotoBloc>().add(PhotoFetched(widget.albumId));
}
}
bool get _isBottom {
if (!_scrollController.hasClients) {
return false;
}
final maxScroll = _scrollController.position.maxScrollExtent;
final currentScroll = _scrollController.offset;
return currentScroll >= (maxScroll * 0.9);
}
@override
Widget build(BuildContext context) => GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisSpacing: 4,
mainAxisSpacing: 4,
crossAxisCount: 4,
),
itemCount: widget.hasReachedMax
? widget.photos.length
: widget.photos.length + 1,
controller: _scrollController,
itemBuilder: (context, index) => index >= widget.photos.length
? const BottomLoader()
: PhotosGridThumbnail(photo: widget.photos[index]),
);
}
@@ -0,0 +1,63 @@
// Copyright (C) 2022 WYATT GROUP
// Please see the AUTHORS file for details.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import 'package:architecture_example/domain/entities/photo.dart';
import 'package:architecture_example/domain/repositories/photo_repository.dart';
import 'package:architecture_example/domain/usecases/photos/check_if_photo_is_in_favorites.dart';
import 'package:architecture_example/presentation/features/photo_details/photo_details.dart';
import 'package:architecture_example/presentation/features/photos/blocs/favorite_checker/favorite_checker_cubit.dart';
import 'package:flutter/material.dart';
import 'package:wyatt_bloc_helper/wyatt_bloc_helper.dart';
class PhotosGridThumbnail
extends CubitScreen<FavoriteCheckerCubit, FavoriteCheckerState> {
const PhotosGridThumbnail({required this.photo, super.key});
final Photo photo;
@override
FavoriteCheckerCubit create(BuildContext context) => FavoriteCheckerCubit(
CheckIfPhotoIsInFavorites(repo<PhotoRepository>(context)),
);
@override
FavoriteCheckerCubit init(BuildContext context, FavoriteCheckerCubit bloc) =>
bloc..checkIfPhotoIsInFavorites(photo.id);
@override
Widget onBuild(BuildContext context, FavoriteCheckerState state) =>
GestureDetector(
onTap: () => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (context) => PhotoDetails(photoId: photo.id),
),
),
child: Stack(
children: [
Positioned.fill(
child: Image.network(
photo.thumbnailUrl,
fit: BoxFit.cover,
),
),
if (state is FavoriteCheckerSuccess && state.isFavorite) ...[
const Positioned(
bottom: 10, right: 10, child: Icon(Icons.favorite),)
]
],
),
);
}