diff --git a/app/lib/bloc/search_landing.dart b/app/lib/bloc/search_landing.dart deleted file mode 100644 index 8a056851..00000000 --- a/app/lib/bloc/search_landing.dart +++ /dev/null @@ -1,125 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:logging/logging.dart'; -import 'package:nc_photos/account.dart'; -import 'package:nc_photos/controller/account_pref_controller.dart'; -import 'package:nc_photos/di_container.dart'; -import 'package:nc_photos/entity/person.dart'; -import 'package:nc_photos/use_case/list_location_group.dart'; -import 'package:nc_photos/use_case/person/list_person.dart'; -import 'package:np_codegen/np_codegen.dart'; -import 'package:to_string/to_string.dart'; - -part 'search_landing.g.dart'; - -abstract class SearchLandingBlocEvent { - const SearchLandingBlocEvent(); -} - -@toString -class SearchLandingBlocQuery extends SearchLandingBlocEvent { - const SearchLandingBlocQuery(this.account, this.accountPrefController); - - @override - String toString() => _$toString(); - - final Account account; - final AccountPrefController accountPrefController; -} - -@toString -abstract class SearchLandingBlocState { - const SearchLandingBlocState(this.account, this.persons, this.locations); - - @override - String toString() => _$toString(); - - final Account? account; - final List persons; - final LocationGroupResult locations; -} - -class SearchLandingBlocInit extends SearchLandingBlocState { - SearchLandingBlocInit() - : super(null, const [], const LocationGroupResult([], [], [], [])); -} - -class SearchLandingBlocLoading extends SearchLandingBlocState { - const SearchLandingBlocLoading( - Account? account, List persons, LocationGroupResult locations) - : super(account, persons, locations); -} - -class SearchLandingBlocSuccess extends SearchLandingBlocState { - const SearchLandingBlocSuccess( - Account? account, List persons, LocationGroupResult locations) - : super(account, persons, locations); -} - -@toString -class SearchLandingBlocFailure extends SearchLandingBlocState { - const SearchLandingBlocFailure(Account? account, List persons, - LocationGroupResult locations, this.exception) - : super(account, persons, locations); - - @override - String toString() => _$toString(); - - final Object exception; -} - -@npLog -class SearchLandingBloc - extends Bloc { - SearchLandingBloc(this._c) : super(SearchLandingBlocInit()) { - on(_onEvent); - } - - Future _onEvent(SearchLandingBlocEvent event, - Emitter emit) async { - _log.info("[_onEvent] $event"); - if (event is SearchLandingBlocQuery) { - await _onEventQuery(event, emit); - } - } - - Future _onEventQuery( - SearchLandingBlocQuery ev, Emitter emit) async { - try { - emit( - SearchLandingBlocLoading(ev.account, state.persons, state.locations)); - - List? persons; - try { - persons = await _queryPeople(ev); - } catch (e, stackTrace) { - _log.shout("[_onEventQuery] Failed while _queryPeople", e, stackTrace); - } - - LocationGroupResult? locations; - try { - locations = await _queryLocations(ev); - } catch (e, stackTrace) { - _log.shout( - "[_onEventQuery] Failed while _queryLocations", e, stackTrace); - } - - emit(SearchLandingBlocSuccess(ev.account, persons ?? [], - locations ?? const LocationGroupResult([], [], [], []))); - } catch (e, stackTrace) { - _log.severe("[_onEventQuery] Exception while request", e, stackTrace); - emit(SearchLandingBlocFailure( - ev.account, state.persons, state.locations, e)); - } - } - - Future> _queryPeople(SearchLandingBlocQuery ev) => - ListPerson(_c.withLocalRepo())( - ev.account, ev.accountPrefController.personProvider.value) - .last; - - Future _queryLocations(SearchLandingBlocQuery ev) => - ListLocationGroup(_c.withLocalRepo())(ev.account); - - final DiContainer _c; -} diff --git a/app/lib/bloc/search_landing.g.dart b/app/lib/bloc/search_landing.g.dart deleted file mode 100644 index dd59c630..00000000 --- a/app/lib/bloc/search_landing.g.dart +++ /dev/null @@ -1,39 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'search_landing.dart'; - -// ************************************************************************** -// NpLogGenerator -// ************************************************************************** - -extension _$SearchLandingBlocNpLog on SearchLandingBloc { - // ignore: unused_element - Logger get _log => log; - - static final log = Logger("bloc.search_landing.SearchLandingBloc"); -} - -// ************************************************************************** -// ToStringGenerator -// ************************************************************************** - -extension _$SearchLandingBlocQueryToString on SearchLandingBlocQuery { - String _$toString() { - // ignore: unnecessary_string_interpolations - return "SearchLandingBlocQuery {account: $account, accountPrefController: $accountPrefController}"; - } -} - -extension _$SearchLandingBlocStateToString on SearchLandingBlocState { - String _$toString() { - // ignore: unnecessary_string_interpolations - return "${objectRuntimeType(this, "SearchLandingBlocState")} {account: $account, persons: [length: ${persons.length}], locations: $locations}"; - } -} - -extension _$SearchLandingBlocFailureToString on SearchLandingBlocFailure { - String _$toString() { - // ignore: unnecessary_string_interpolations - return "SearchLandingBlocFailure {account: $account, persons: [length: ${persons.length}], locations: $locations, exception: $exception}"; - } -} diff --git a/app/lib/widget/home_search.dart b/app/lib/widget/home_search.dart index 16034b2f..d908d52c 100644 --- a/app/lib/widget/home_search.dart +++ b/app/lib/widget/home_search.dart @@ -132,7 +132,6 @@ class _HomeSearchState extends State if (_isShowLanding(state)) SliverToBoxAdapter( child: SearchLanding( - account: widget.account, onFavoritePressed: _onLandingFavoritePressed, onVideoPressed: _onLandingVideoPressed, ), diff --git a/app/lib/widget/search_landing.dart b/app/lib/widget/search_landing.dart index a85fe85d..327b5ccc 100644 --- a/app/lib/widget/search_landing.dart +++ b/app/lib/widget/search_landing.dart @@ -1,20 +1,25 @@ +import 'dart:async'; + import 'package:collection/collection.dart'; +import 'package:copy_with/copy_with.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:kiwi/kiwi.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/app_localizations.dart'; -import 'package:nc_photos/bloc/search_landing.dart'; +import 'package:nc_photos/bloc_util.dart'; import 'package:nc_photos/controller/account_controller.dart'; -import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/controller/persons_controller.dart'; +import 'package:nc_photos/controller/places_controller.dart'; import 'package:nc_photos/entity/collection/builder.dart'; import 'package:nc_photos/entity/person.dart'; import 'package:nc_photos/exception.dart'; +import 'package:nc_photos/exception_event.dart'; import 'package:nc_photos/exception_util.dart' as exception_util; import 'package:nc_photos/help_utils.dart' as help_util; import 'package:nc_photos/k.dart' as k; import 'package:nc_photos/snack_bar_manager.dart'; +import 'package:nc_photos/stream_util.dart'; import 'package:nc_photos/theme.dart'; import 'package:nc_photos/url_launcher_util.dart'; import 'package:nc_photos/use_case/list_location_group.dart'; @@ -25,417 +30,348 @@ import 'package:nc_photos/widget/person_thumbnail.dart'; import 'package:nc_photos/widget/places_browser.dart'; import 'package:nc_photos/widget/settings/account_settings.dart'; import 'package:np_codegen/np_codegen.dart'; +import 'package:to_string/to_string.dart'; +import 'package:visibility_detector/visibility_detector.dart'; part 'search_landing.g.dart'; +part 'search_landing/bloc.dart'; +part 'search_landing/state_event.dart'; part 'search_landing/type.dart'; part 'search_landing/view.dart'; -class SearchLanding extends StatefulWidget { +typedef _BlocBuilder = BlocBuilder<_Bloc, _State>; +typedef _BlocListener = BlocListener<_Bloc, _State>; + +class SearchLanding extends StatelessWidget { const SearchLanding({ - Key? key, - required this.account, + super.key, this.onFavoritePressed, this.onVideoPressed, - }) : super(key: key); + }); @override - createState() => _SearchLandingState(); + Widget build(BuildContext context) { + final accountController = context.read(); + return BlocProvider( + create: (_) => _Bloc( + account: accountController.account, + personsController: accountController.personsController, + placesController: accountController.placesController, + ), + child: _WrappedSearchLanding( + onFavoritePressed: onFavoritePressed, + onVideoPressed: onVideoPressed, + ), + ); + } + + final VoidCallback? onFavoritePressed; + final VoidCallback? onVideoPressed; +} + +class _WrappedSearchLanding extends StatefulWidget { + const _WrappedSearchLanding({ + this.onFavoritePressed, + this.onVideoPressed, + }); + + @override + State createState() => _WrappedSearchLandingState(); - final Account account; final VoidCallback? onFavoritePressed; final VoidCallback? onVideoPressed; } @npLog -class _SearchLandingState extends State { +class _WrappedSearchLandingState extends State<_WrappedSearchLanding> { @override - initState() { + void initState() { super.initState(); - _initBloc(); + _bloc + ..add(const _LoadPersons()) + ..add(const _LoadPlaces()); } @override - build(BuildContext context) { - return BlocListener( - bloc: _bloc, - listener: (context, state) => _onStateChange(context, state), - child: BlocBuilder( - bloc: _bloc, - builder: (context, state) => _buildContent(context, state), + Widget build(BuildContext context) { + return VisibilityDetector( + key: _key, + onVisibilityChanged: (info) { + final isVisible = info.visibleFraction >= 0.2; + if (isVisible != _isVisible) { + if (mounted) { + setState(() { + _isVisible = isVisible; + }); + } + } + }, + child: MultiBlocListener( + listeners: [ + _BlocListener( + listenWhen: (previous, current) => + previous.persons != current.persons, + listener: (context, state) { + _bloc.add(_TransformPersonItems(state.persons)); + }, + ), + _BlocListener( + listenWhen: (previous, current) => + previous.places != current.places, + listener: (context, state) { + _bloc.add(_TransformPlaceItems(state.places)); + }, + ), + _BlocListener( + listenWhen: (previous, current) => previous.error != current.error, + listener: (context, state) { + if (state.error != null && _isVisible == true) { + if (state.error is ApiException) { + final e = state.error as ApiException; + if (e.response.statusCode == 404) { + // face recognition app probably not installed, ignore + return; + } + } + SnackBarManager().showSnackBar(SnackBar( + content: + Text(exception_util.toUserString(state.error!.error)), + duration: k.snackBarDurationNormal, + )); + } + }, + ), + ], + child: Column( + children: [ + ValueStreamBuilder( + stream: context + .read() + .accountPrefController + .personProvider, + builder: (context, snapshot) { + if (snapshot.requireData == PersonProvider.none) { + return const SizedBox.shrink(); + } else { + return const _PeopleSection(); + } + }, + ), + const _PlaceSection(), + ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16), + title: Text(L10n.global().categoriesLabel), + ), + ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16), + leading: const Icon(Icons.star_border), + title: Text(L10n.global().collectionFavoritesLabel), + onTap: widget.onFavoritePressed, + ), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 24), + child: Divider(height: 1), + ), + ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16), + leading: const Icon(Icons.ondemand_video_outlined), + title: Text(L10n.global().searchLandingCategoryVideosLabel), + onTap: widget.onVideoPressed, + ), + ], + ), ), ); } - void _initBloc() { - if (_bloc.state is SearchLandingBlocInit) { - _log.info("[_initBloc] Initialize bloc"); - _reqQuery(); - } else { - // process the current state - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - setState(() { - _onStateChange(context, _bloc.state); - }); - } - }); - } - } + late final _bloc = context.read<_Bloc>(); - Widget _buildContent(BuildContext context, SearchLandingBlocState state) { + final _key = GlobalKey(); + bool? _isVisible; +} + +class _PeopleSection extends StatelessWidget { + const _PeopleSection(); + + @override + Widget build(BuildContext context) { return Column( + mainAxisSize: MainAxisSize.min, children: [ - if (context - .read() - .accountPrefController - .personProvider - .value != - PersonProvider.none) - ..._buildPeopleSection(context, state), - ..._buildLocationSection(context, state), ListTile( contentPadding: const EdgeInsets.symmetric(horizontal: 16), - title: Text(L10n.global().categoriesLabel), + title: Text(L10n.global().collectionPeopleLabel), + trailing: _BlocBuilder( + buildWhen: (previous, current) => + previous.transformedPersonItems != + current.transformedPersonItems, + builder: (context, state) => state.transformedPersonItems.isEmpty + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: () { + Navigator.of(context).pushNamed( + AccountSettings.routeName, + arguments: const AccountSettingsArguments( + highlight: AccountSettingsOption.personProvider, + ), + ); + }, + tooltip: L10n.global().accountSettingsTooltip, + icon: const Icon(Icons.settings_outlined), + ), + IconButton( + onPressed: () { + launch(help_util.peopleUrl); + }, + tooltip: L10n.global().helpTooltip, + icon: const Icon(Icons.help_outline), + ), + ], + ) + : TextButton( + onPressed: () { + Navigator.of(context).pushNamed(PeopleBrowser.routeName); + }, + child: Text(L10n.global().showAllButtonLabel), + ), + ), ), - ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 16), - leading: const Icon(Icons.star_border), - title: Text(L10n.global().collectionFavoritesLabel), - onTap: _onFavoritePressed, - ), - const Padding( - padding: EdgeInsets.symmetric(horizontal: 24), - child: Divider(height: 1), - ), - ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 16), - leading: const Icon(Icons.ondemand_video_outlined), - title: Text(L10n.global().searchLandingCategoryVideosLabel), - onTap: _onVideoPressed, + _BlocBuilder( + buildWhen: (previous, current) => + previous.isPersonsLoading != current.isPersonsLoading || + previous.transformedPersonItems != current.transformedPersonItems, + builder: (context, state) { + if (state.isPersonsLoading) { + return const SizedBox( + height: 48, + child: Center(child: CircularProgressIndicator()), + ); + } else { + if (state.transformedPersonItems.isEmpty) { + return SizedBox( + height: 48, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Center( + child: + Text(L10n.global().searchLandingPeopleListEmptyText2), + ), + ), + ); + } else { + return SizedBox( + height: 128, + child: ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 16), + scrollDirection: Axis.horizontal, + itemCount: state.transformedPersonItems.length, + itemBuilder: (context, i) { + final item = state.transformedPersonItems[i]; + return _PersonItemView( + account: context.read<_Bloc>().account, + item: item, + onTap: () => _onItemTap(context, item.person), + ); + }, + ), + ); + } + } + }, ), ], ); } - List _buildPeopleSection( - BuildContext context, SearchLandingBlocState state) { - final isNoResult = (state is SearchLandingBlocSuccess || - state is SearchLandingBlocFailure) && - _personItems.isEmpty; - return [ - ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 16), - title: Text(L10n.global().collectionPeopleLabel), - trailing: isNoResult - ? Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - onPressed: () { - Navigator.of(context).pushNamed( - AccountSettings.routeName, - arguments: const AccountSettingsArguments( - highlight: AccountSettingsOption.personProvider, - ), - ); - }, - tooltip: L10n.global().accountSettingsTooltip, - icon: const Icon(Icons.settings_outlined), - ), - IconButton( - onPressed: () { - launch(help_util.peopleUrl); - }, - tooltip: L10n.global().helpTooltip, - icon: const Icon(Icons.help_outline), - ), - ], - ) - : TextButton( - onPressed: () { - Navigator.of(context).pushNamed(PeopleBrowser.routeName); - }, - child: Text(L10n.global().showAllButtonLabel), - ), - ), - if (isNoResult) - SizedBox( - height: 48, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 32), - child: Center( - child: Text(L10n.global().searchLandingPeopleListEmptyText2), - ), - ), - ) - else - SizedBox( - height: 128, - child: ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: 16), - scrollDirection: Axis.horizontal, - itemCount: _personItems.length, - itemBuilder: (context, i) => _personItems[i].buildWidget(context), - ), - ), - ]; - } - - List _buildLocationSection( - BuildContext context, SearchLandingBlocState state) { - final isNoResult = (state is SearchLandingBlocSuccess || - state is SearchLandingBlocFailure) && - _locationItems.isEmpty; - return [ - ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 16), - title: Text(L10n.global().collectionPlacesLabel), - trailing: isNoResult - ? null - : TextButton( - onPressed: () { - Navigator.of(context).pushNamed(PlacesBrowser.routeName); - }, - child: Text(L10n.global().showAllButtonLabel), - ), - ), - if (isNoResult) - SizedBox( - height: 48, - child: Center( - child: Text(L10n.global().listNoResultsText), - ), - ) - else - SizedBox( - height: 128, - child: ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: 16), - scrollDirection: Axis.horizontal, - itemCount: _locationItems.length, - itemBuilder: (context, i) => _locationItems[i].buildWidget(context), - ), - ), - ]; - } - - void _onStateChange(BuildContext context, SearchLandingBlocState state) { - if (state is SearchLandingBlocInit) { - _personItems = []; - _locationItems = []; - } else if (state is SearchLandingBlocSuccess || - state is SearchLandingBlocLoading) { - _transformItems(state.persons, state.locations); - } else if (state is SearchLandingBlocFailure) { - _transformItems(state.persons, state.locations); - try { - final e = state.exception as ApiException; - if (e.response.statusCode == 404) { - // face recognition app probably not installed, ignore - return; - } - } catch (_) {} - SnackBarManager().showSnackBar(SnackBar( - content: Text(exception_util.toUserString(state.exception)), - duration: k.snackBarDurationNormal, - )); - } - } - - void _onFavoritePressed() { - widget.onFavoritePressed?.call(); - } - - void _onVideoPressed() { - widget.onVideoPressed?.call(); - } - - void _onPersonItemTap(Person person) { + void _onItemTap(BuildContext context, Person person) { Navigator.pushNamed( context, CollectionBrowser.routeName, arguments: CollectionBrowserArguments( - CollectionBuilder.byPerson(widget.account, person), + CollectionBuilder.byPerson(context.read<_Bloc>().account, person), ), ); } +} - void _onLocationItemTap(LocationGroup location) { +class _PlaceSection extends StatelessWidget { + const _PlaceSection(); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16), + title: Text(L10n.global().collectionPlacesLabel), + trailing: _BlocBuilder( + buildWhen: (previous, current) => + previous.transformedPlaceItems != current.transformedPlaceItems, + builder: (context, state) => state.transformedPlaceItems.isEmpty + ? const SizedBox.shrink() + : TextButton( + onPressed: () { + Navigator.of(context).pushNamed(PlacesBrowser.routeName); + }, + child: Text(L10n.global().showAllButtonLabel), + ), + ), + ), + _BlocBuilder( + buildWhen: (previous, current) => + previous.isPlacesLoading != current.isPlacesLoading || + previous.transformedPlaceItems != current.transformedPlaceItems, + builder: (context, state) { + if (state.isPlacesLoading) { + return const SizedBox( + height: 48, + child: Center(child: CircularProgressIndicator()), + ); + } else { + if (state.transformedPlaceItems.isEmpty) { + return SizedBox( + height: 48, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Center( + child: Text(L10n.global().listNoResultsText), + ), + ), + ); + } else { + return SizedBox( + height: 128, + child: ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 16), + scrollDirection: Axis.horizontal, + itemCount: state.transformedPlaceItems.length, + itemBuilder: (context, i) { + final item = state.transformedPlaceItems[i]; + return _PlaceItemView( + account: context.read<_Bloc>().account, + item: item, + onTap: () => _onItemTap(context, item.place), + ); + }, + ), + ); + } + } + }, + ), + ], + ); + } + + void _onItemTap(BuildContext context, LocationGroup place) { Navigator.of(context).pushNamed( CollectionBrowser.routeName, arguments: CollectionBrowserArguments( - CollectionBuilder.byLocationGroup(widget.account, location), + CollectionBuilder.byLocationGroup(context.read<_Bloc>().account, place), ), ); } - - void _transformItems(List persons, LocationGroupResult locations) { - _transformPersons(persons); - _transformLocations(locations); - } - - void _transformPersons(List persons) { - _personItems = persons - .sorted((a, b) { - final countCompare = (b.count ?? 0).compareTo(a.count ?? 0); - if (countCompare == 0) { - return a.name.compareTo(b.name); - } else { - return countCompare; - } - }) - .take(10) - .map((e) => _LandingPersonItem( - account: widget.account, - person: e, - onTap: () => _onPersonItemTap(e), - )) - .toList(); - } - - void _transformLocations(LocationGroupResult locations) { - _locationItems = locations.name - .sorted((a, b) { - final compare = b.count.compareTo(a.count); - if (compare == 0) { - return a.place.compareTo(b.place); - } else { - return compare; - } - }) - .take(10) - .map((e) => _LandingLocationItem( - account: widget.account, - name: e.place, - thumbUrl: NetworkRectThumbnail.imageUrlForFileId( - widget.account, e.latestFileId), - onTap: () => _onLocationItemTap(e), - )) - .toList(); - } - - void _reqQuery() { - _bloc.add(SearchLandingBlocQuery(widget.account, _accountPrefController)); - } - - late final _bloc = SearchLandingBloc(KiwiContainer().resolve()); - late final _accountPrefController = - context.read().accountPrefController; - - var _personItems = <_LandingPersonItem>[]; - var _locationItems = <_LandingLocationItem>[]; -} - -class _LandingPersonWidget extends StatelessWidget { - const _LandingPersonWidget({ - required this.account, - required this.person, - required this.label, - required this.coverUrl, - this.onTap, - }); - - @override - Widget build(BuildContext context) { - final content = Padding( - padding: const EdgeInsets.all(4), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Center( - child: ClipRRect( - borderRadius: BorderRadius.circular(72 / 2), - child: PersonThumbnail( - dimension: 72, - account: account, - person: person, - coverUrl: coverUrl, - ), - ), - ), - const SizedBox(height: 8), - Expanded(child: _Label(label: label)), - ], - ), - ); - if (onTap != null) { - return InkWell( - onTap: onTap, - child: content, - ); - } else { - return content; - } - } - - final Account account; - final Person person; - final String label; - final String? coverUrl; - final VoidCallback? onTap; -} - -class _LandingLocationWidget extends StatelessWidget { - const _LandingLocationWidget({ - required this.account, - required this.label, - required this.coverUrl, - this.onTap, - }); - - @override - Widget build(BuildContext context) { - final content = Padding( - padding: const EdgeInsets.all(4), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Center( - child: _LocationCoverImage( - dimension: 72, - account: account, - coverUrl: coverUrl, - ), - ), - const SizedBox(height: 8), - Expanded(child: _Label(label: label)), - ], - ), - ); - if (onTap != null) { - return InkWell( - onTap: onTap, - child: content, - ); - } else { - return content; - } - } - - final Account account; - final String label; - final String? coverUrl; - final VoidCallback? onTap; -} - -class _Label extends StatelessWidget { - const _Label({ - required this.label, - }); - - @override - Widget build(BuildContext context) { - return SizedBox( - width: 88, - child: Text( - label + "\n", - style: Theme.of(context).textTheme.bodyMedium, - textAlign: TextAlign.center, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ); - } - - final String label; } diff --git a/app/lib/widget/search_landing.g.dart b/app/lib/widget/search_landing.g.dart index 55d6b85d..2d95a972 100644 --- a/app/lib/widget/search_landing.g.dart +++ b/app/lib/widget/search_landing.g.dart @@ -2,13 +2,126 @@ part of 'search_landing.dart'; +// ************************************************************************** +// CopyWithLintRuleGenerator +// ************************************************************************** + +// ignore_for_file: library_private_types_in_public_api, duplicate_ignore + +// ************************************************************************** +// CopyWithGenerator +// ************************************************************************** + +abstract class $_StateCopyWithWorker { + _State call( + {List? persons, + bool? isPersonsLoading, + List<_PersonItem>? transformedPersonItems, + LocationGroupResult? places, + bool? isPlacesLoading, + List<_PlaceItem>? transformedPlaceItems, + ExceptionEvent? error}); +} + +class _$_StateCopyWithWorkerImpl implements $_StateCopyWithWorker { + _$_StateCopyWithWorkerImpl(this.that); + + @override + _State call( + {dynamic persons, + dynamic isPersonsLoading, + dynamic transformedPersonItems, + dynamic places, + dynamic isPlacesLoading, + dynamic transformedPlaceItems, + dynamic error = copyWithNull}) { + return _State( + persons: persons as List? ?? that.persons, + isPersonsLoading: isPersonsLoading as bool? ?? that.isPersonsLoading, + transformedPersonItems: transformedPersonItems as List<_PersonItem>? ?? + that.transformedPersonItems, + places: places as LocationGroupResult? ?? that.places, + isPlacesLoading: isPlacesLoading as bool? ?? that.isPlacesLoading, + transformedPlaceItems: transformedPlaceItems as List<_PlaceItem>? ?? + that.transformedPlaceItems, + error: error == copyWithNull ? that.error : error as ExceptionEvent?); + } + + final _State that; +} + +extension $_StateCopyWith on _State { + $_StateCopyWithWorker get copyWith => _$copyWith; + $_StateCopyWithWorker get _$copyWith => _$_StateCopyWithWorkerImpl(this); +} + // ************************************************************************** // NpLogGenerator // ************************************************************************** -extension _$_SearchLandingStateNpLog on _SearchLandingState { +extension _$_WrappedSearchLandingStateNpLog on _WrappedSearchLandingState { // ignore: unused_element Logger get _log => log; - static final log = Logger("widget.search_landing._SearchLandingState"); + static final log = Logger("widget.search_landing._WrappedSearchLandingState"); +} + +extension _$_BlocNpLog on _Bloc { + // ignore: unused_element + Logger get _log => log; + + static final log = Logger("widget.search_landing._Bloc"); +} + +extension _$_PersonItemNpLog on _PersonItem { + // ignore: unused_element + Logger get _log => log; + + static final log = Logger("widget.search_landing._PersonItem"); +} + +extension _$_PlaceItemNpLog on _PlaceItem { + // ignore: unused_element + Logger get _log => log; + + static final log = Logger("widget.search_landing._PlaceItem"); +} + +// ************************************************************************** +// ToStringGenerator +// ************************************************************************** + +extension _$_StateToString on _State { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_State {persons: [length: ${persons.length}], isPersonsLoading: $isPersonsLoading, transformedPersonItems: [length: ${transformedPersonItems.length}], places: $places, isPlacesLoading: $isPlacesLoading, transformedPlaceItems: [length: ${transformedPlaceItems.length}], error: $error}"; + } +} + +extension _$_LoadPersonsToString on _LoadPersons { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_LoadPersons {}"; + } +} + +extension _$_TransformPersonItemsToString on _TransformPersonItems { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_TransformPersonItems {persons: [length: ${persons.length}]}"; + } +} + +extension _$_LoadPlacesToString on _LoadPlaces { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_LoadPlaces {}"; + } +} + +extension _$_TransformPlaceItemsToString on _TransformPlaceItems { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_TransformPlaceItems {places: $places}"; + } } diff --git a/app/lib/widget/search_landing/bloc.dart b/app/lib/widget/search_landing/bloc.dart new file mode 100644 index 00000000..12b96ba4 --- /dev/null +++ b/app/lib/widget/search_landing/bloc.dart @@ -0,0 +1,94 @@ +part of '../search_landing.dart'; + +@npLog +class _Bloc extends Bloc<_Event, _State> with BlocLogger { + _Bloc({ + required this.account, + required this.personsController, + required this.placesController, + }) : super(_State.init()) { + on<_LoadPersons>(_onLoadPersons); + on<_TransformPersonItems>(_onTransformPersonItems); + on<_LoadPlaces>(_onLoadPlaces); + on<_TransformPlaceItems>(_onTransformPlaceItems); + } + + @override + String get tag => _log.fullName; + + Future _onLoadPersons(_LoadPersons ev, Emitter<_State> emit) { + _log.info(ev); + return emit.forEach( + personsController.stream, + onData: (data) => state.copyWith( + persons: data.data, + isPersonsLoading: data.hasNext, + ), + onError: (e, stackTrace) { + _log.severe("[_onLoadPersons] Uncaught exception", e, stackTrace); + return state.copyWith( + isPersonsLoading: false, + error: ExceptionEvent(e, stackTrace), + ); + }, + ); + } + + Future _onTransformPersonItems( + _TransformPersonItems ev, Emitter<_State> emit) async { + _log.info(ev); + final transformed = ev.persons + .sorted((a, b) { + final countCompare = (b.count ?? 0).compareTo(a.count ?? 0); + if (countCompare == 0) { + return a.name.compareTo(b.name); + } else { + return countCompare; + } + }) + .take(10) + .map(_PersonItem.new) + .toList(); + emit(state.copyWith(transformedPersonItems: transformed)); + } + + Future _onLoadPlaces(_LoadPlaces ev, Emitter<_State> emit) { + _log.info(ev); + return emit.forEach( + placesController.stream, + onData: (data) => state.copyWith( + places: data.data, + isPlacesLoading: data.hasNext, + ), + onError: (e, stackTrace) { + _log.severe("[_onLoadPlaces] Uncaught exception", e, stackTrace); + return state.copyWith( + isPlacesLoading: false, + error: ExceptionEvent(e, stackTrace), + ); + }, + ); + } + + Future _onTransformPlaceItems( + _TransformPlaceItems ev, Emitter<_State> emit) async { + _log.info(ev); + final transformed = ev.places.name + .sorted((a, b) { + final compare = b.count.compareTo(a.count); + if (compare == 0) { + return a.place.compareTo(b.place); + } else { + return compare; + } + }) + .take(10) + .map((e) => _PlaceItem(account: account, place: e)) + .toList(); + emit(state.copyWith(transformedPlaceItems: transformed)); + } + + final Account account; + final PersonsController personsController; + final PlacesController placesController; +} diff --git a/app/lib/widget/search_landing/state_event.dart b/app/lib/widget/search_landing/state_event.dart new file mode 100644 index 00000000..5603bff0 --- /dev/null +++ b/app/lib/widget/search_landing/state_event.dart @@ -0,0 +1,77 @@ +part of '../search_landing.dart'; + +@genCopyWith +@toString +class _State { + const _State({ + required this.persons, + required this.isPersonsLoading, + required this.transformedPersonItems, + required this.places, + required this.isPlacesLoading, + required this.transformedPlaceItems, + this.error, + }); + + factory _State.init() => const _State( + persons: [], + isPersonsLoading: false, + transformedPersonItems: [], + places: LocationGroupResult([], [], [], []), + isPlacesLoading: false, + transformedPlaceItems: [], + ); + + @override + String toString() => _$toString(); + + final List persons; + final bool isPersonsLoading; + final List<_PersonItem> transformedPersonItems; + final LocationGroupResult places; + final bool isPlacesLoading; + final List<_PlaceItem> transformedPlaceItems; + + final ExceptionEvent? error; +} + +abstract class _Event {} + +/// Load the list of [Person]s belonging to this account +@toString +class _LoadPersons implements _Event { + const _LoadPersons(); + + @override + String toString() => _$toString(); +} + +@toString +class _TransformPersonItems implements _Event { + const _TransformPersonItems(this.persons); + + @override + String toString() => _$toString(); + + final List persons; +} + +/// Load the location groups belonging to this account +@toString +class _LoadPlaces implements _Event { + const _LoadPlaces(); + + @override + String toString() => _$toString(); +} + +/// Transform the location groups (e.g., filtering, sorting, etc) +@toString +class _TransformPlaceItems implements _Event { + const _TransformPlaceItems(this.places); + + @override + String toString() => _$toString(); + + final LocationGroupResult places; +} diff --git a/app/lib/widget/search_landing/type.dart b/app/lib/widget/search_landing/type.dart index 061bb62e..8d58094b 100644 --- a/app/lib/widget/search_landing/type.dart +++ b/app/lib/widget/search_landing/type.dart @@ -1,49 +1,46 @@ part of '../search_landing.dart'; -class _LandingPersonItem { - _LandingPersonItem({ - required this.account, - required this.person, - this.onTap, - }) : name = person.name, - faceUrl = person.getCoverUrl( - k.photoLargeSize, - k.photoLargeSize, - isKeepAspectRatio: true, - ); - - Widget buildWidget(BuildContext context) => _LandingPersonWidget( - account: account, - person: person, - label: name, - coverUrl: faceUrl, - onTap: onTap, +@npLog +class _PersonItem { + _PersonItem(this.person) { + try { + _coverUrl = person.getCoverUrl( + k.photoLargeSize, + k.photoLargeSize, + isKeepAspectRatio: true, ); + } catch (e, stackTrace) { + _log.warning("[_PersonItem] Failed while getCoverUrl", e, stackTrace); + } + } + + String get name => person.name; + String? get coverUrl => _coverUrl; - final Account account; final Person person; - final String name; - final String? faceUrl; - final VoidCallback? onTap; + + String? _coverUrl; } -class _LandingLocationItem { - const _LandingLocationItem({ - required this.account, - required this.name, - required this.thumbUrl, - this.onTap, - }); +@npLog +class _PlaceItem { + _PlaceItem({ + required Account account, + required this.place, + }) { + try { + _coverUrl = + NetworkRectThumbnail.imageUrlForFileId(account, place.latestFileId); + } catch (e, stackTrace) { + _log.warning( + "[_PlaceItem] Failed while imageUrlForFileId", e, stackTrace); + } + } - Widget buildWidget(BuildContext context) => _LandingLocationWidget( - account: account, - label: name, - coverUrl: thumbUrl, - onTap: onTap, - ); + String get name => place.place; + String? get coverUrl => _coverUrl; - final Account account; - final String name; - final String thumbUrl; - final VoidCallback? onTap; + final LocationGroup place; + + String? _coverUrl; } diff --git a/app/lib/widget/search_landing/view.dart b/app/lib/widget/search_landing/view.dart index ff2dfddf..f6b86562 100644 --- a/app/lib/widget/search_landing/view.dart +++ b/app/lib/widget/search_landing/view.dart @@ -1,5 +1,113 @@ part of '../search_landing.dart'; +class _PersonItemView extends StatelessWidget { + const _PersonItemView({ + required this.account, + required this.item, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final content = Padding( + padding: const EdgeInsets.all(4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Center( + child: ClipRRect( + borderRadius: BorderRadius.circular(72 / 2), + child: PersonThumbnail( + dimension: 72, + account: account, + person: item.person, + coverUrl: item.coverUrl, + ), + ), + ), + const SizedBox(height: 8), + Expanded(child: _LabelView(label: item.name)), + ], + ), + ); + if (onTap != null) { + return InkWell( + onTap: onTap, + child: content, + ); + } else { + return content; + } + } + + final Account account; + final _PersonItem item; + final VoidCallback? onTap; +} + +class _PlaceItemView extends StatelessWidget { + const _PlaceItemView({ + required this.account, + required this.item, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final content = Padding( + padding: const EdgeInsets.all(4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Center( + child: _LocationCoverImage( + dimension: 72, + account: account, + coverUrl: item.coverUrl, + ), + ), + const SizedBox(height: 8), + Expanded(child: _LabelView(label: item.name)), + ], + ), + ); + if (onTap != null) { + return InkWell( + onTap: onTap, + child: content, + ); + } else { + return content; + } + } + + final Account account; + final _PlaceItem item; + final VoidCallback? onTap; +} + +class _LabelView extends StatelessWidget { + const _LabelView({ + required this.label, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 88, + child: Text( + label + "\n", + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ); + } + + final String label; +} + class _LocationCoverPlaceholder extends StatelessWidget { const _LocationCoverPlaceholder();