diff --git a/lib/features/trips/controller/trip_belonging_controller.dart b/lib/features/trips/controller/trip_belonging_controller.dart index 6cbe7b51..a76b0e89 100644 --- a/lib/features/trips/controller/trip_belonging_controller.dart +++ b/lib/features/trips/controller/trip_belonging_controller.dart @@ -1,6 +1,9 @@ +import 'package:flutter/material.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:trip_app_nativeapp/core/exception/exception_handler.dart'; import 'package:trip_app_nativeapp/features/trips/domain/entity/trip/trip_belonging.dart'; import 'package:trip_app_nativeapp/features/trips/domain/interactor/trip_interactor.dart'; +import 'package:trip_app_nativeapp/view/widgets/common/loading.dart'; part 'trip_belonging_controller.g.dart'; @@ -8,8 +11,29 @@ part 'trip_belonging_controller.g.dart'; class TripBelongingsController extends _$TripBelongingsController { @override FutureOr> build({required int tripId}) async { - return ref - .read(tripInteractorProvider) - .fetchTripBelongings(tripId); + return ref.read(tripInteractorProvider).fetchTripBelongings(tripId); + } + + Future addBelonging({ + required int tripId, + required String name, + required int numOf, + required bool isShareAmongMember, + VoidCallback? onFinished, + }) async { + try { + final result = await ref.read(tripInteractorProvider).addTripBelonging( + tripId: tripId, + name: name, + numOf: numOf, + isShareAmongMember: isShareAmongMember, + ); + state = AsyncValue.data([result, ...state.value ?? []]); + } on Exception catch (e) { + ref.read(exceptionHandlerProvider).handleException(e); + } finally { + ref.read(overlayLoadingProvider.notifier).endLoading(); + onFinished?.call(); + } } } diff --git a/lib/features/trips/domain/interactor/trip_interactor.dart b/lib/features/trips/domain/interactor/trip_interactor.dart index 1a96251b..e3b40c69 100644 --- a/lib/features/trips/domain/interactor/trip_interactor.dart +++ b/lib/features/trips/domain/interactor/trip_interactor.dart @@ -3,6 +3,8 @@ import 'package:trip_app_nativeapp/features/trips/data/repositories/trip_reposit import 'package:trip_app_nativeapp/features/trips/domain/entity/trip/trip.dart'; import 'package:trip_app_nativeapp/features/trips/domain/entity/trip/trip_belonging.dart'; import 'package:trip_app_nativeapp/features/trips/domain/entity/trip/trip_invitation.dart'; +import 'package:trip_app_nativeapp/features/trips/domain/entity/trip/value/trip_belonging_name.dart'; +import 'package:trip_app_nativeapp/features/trips/domain/entity/trip/value/trip_belonging_num.dart'; import 'package:trip_app_nativeapp/features/trips/domain/entity/trip/value/trip_invitation_num.dart'; import 'package:trip_app_nativeapp/features/trips/domain/entity/trip/value/trip_period.dart'; import 'package:trip_app_nativeapp/features/trips/domain/entity/trip/value/trip_title.dart'; @@ -56,6 +58,21 @@ class TripInteractor { Future> fetchTripsByUserId(int userId) => tripRepo.fetchTripsByUserId(userId); + Future addTripBelonging({ + required int tripId, + required String name, + required int numOf, + required bool isShareAmongMember, + }) { + final belonging = TripBelonging.createNewTripBelonging( + name: TripBelongingName(value: name), + numOf: TripBelongingNum(value: numOf), + isShareAmongMember: isShareAmongMember, + ) as NewTripBelonging; + + return tripRepo.addTripBelonging(tripId, belonging); + } + Future> fetchTripBelongings(int tripId) => tripRepo.fetchTripBelongings(tripId); } diff --git a/lib/view/widgets/trips/add_trip_belonging_sheet.dart b/lib/view/widgets/trips/add_trip_belonging_sheet.dart new file mode 100644 index 00000000..d9c302cc --- /dev/null +++ b/lib/view/widgets/trips/add_trip_belonging_sheet.dart @@ -0,0 +1,208 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:trip_app_nativeapp/core/extensions/build_context.dart'; +import 'package:trip_app_nativeapp/features/trips/controller/trip_belonging_controller.dart'; + +class AddTripBelongingSheet extends HookConsumerWidget { + const AddTripBelongingSheet(this.tripId, {super.key}); + + final int tripId; + @override + Widget build(BuildContext context, WidgetRef ref) { + final titleEditingController = useTextEditingController(); + final numOfEditingController = useTextEditingController(); + + final isTitleEmpty = useState(true); + final isNumOfEmpty = useState(true); + final isShareAmongMember = useState(false); + + useEffect( + () { + titleEditingController.addListener( + () => isTitleEmpty.value = titleEditingController.text.isEmpty, + ); + numOfEditingController.addListener( + () => isNumOfEmpty.value = numOfEditingController.text.isEmpty, + ); + return null; + }, + [titleEditingController, numOfEditingController], + ); + + return Container( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + height: 280, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _TitleTextField( + controller: titleEditingController, + isTitleEmpty: isTitleEmpty, + ), + _NumOfForm( + controller: numOfEditingController, + isNumEmpty: isNumOfEmpty, + ), + _IsShareCheckBoxRow(isShareAmongMember: isShareAmongMember), + _CreateButton( + tripId: tripId, + titleEditingController: titleEditingController, + numOfEditingController: numOfEditingController, + isTitleEmpty: isTitleEmpty, + isNumOfEmpty: isNumOfEmpty, + isShareAmongMember: isShareAmongMember, + ), + ], + ), + ); + } +} + +class _TitleTextField extends StatelessWidget { + const _TitleTextField({ + required this.controller, + required this.isTitleEmpty, + }); + + final TextEditingController controller; + final ValueNotifier isTitleEmpty; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isTitleEmpty.value + ? context.theme.colorScheme.secondaryContainer + : context.theme.colorScheme.primary, + ), + ), + child: TextField( + autofocus: true, + controller: controller, + decoration: const InputDecoration( + hintText: '持ち物名🧳', + border: InputBorder.none, + contentPadding: EdgeInsets.all(16), + ), + style: TextStyle( + color: context.theme.colorScheme.primary, + ), + ), + ), + ); + } +} + +class _NumOfForm extends HookWidget { + const _NumOfForm({ + required this.controller, + required this.isNumEmpty, + }); + + final TextEditingController controller; + final ValueNotifier isNumEmpty; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isNumEmpty.value + ? context.theme.colorScheme.secondaryContainer + : context.theme.colorScheme.primary, + ), + ), + child: TextField( + controller: controller, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + hintText: '個数', + border: InputBorder.none, + contentPadding: EdgeInsets.all(16), + ), + style: TextStyle( + color: context.theme.colorScheme.primary, + ), + ), + ), + ); + } +} + +class _IsShareCheckBoxRow extends HookWidget { + const _IsShareCheckBoxRow({ + required this.isShareAmongMember, + }); + + final ValueNotifier isShareAmongMember; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Checkbox( + value: isShareAmongMember.value, + onChanged: (value) => isShareAmongMember.value = value ?? false, + fillColor: context.theme.checkboxTheme.checkColor, + ), + InkWell( + child: const Text('他のメンバーと共有する?'), + onTap: () => isShareAmongMember.value = !isShareAmongMember.value, + ), + ], + ); + } +} + +class _CreateButton extends ConsumerWidget { + const _CreateButton({ + required this.tripId, + required this.titleEditingController, + required this.numOfEditingController, + required this.isTitleEmpty, + required this.isNumOfEmpty, + required this.isShareAmongMember, + }); + + final int tripId; + final TextEditingController titleEditingController; + final TextEditingController numOfEditingController; + final ValueNotifier isTitleEmpty; + final ValueNotifier isNumOfEmpty; + final ValueNotifier isShareAmongMember; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return SizedBox( + width: context.displaySize.width * 0.2, + child: ElevatedButton( + onPressed: isTitleEmpty.value || isNumOfEmpty.value + ? null + : () { + ref + .read( + tripBelongingsControllerProvider(tripId: tripId).notifier, + ) + .addBelonging( + tripId: tripId, + name: titleEditingController.text, + numOf: int.parse(numOfEditingController.text), + isShareAmongMember: isShareAmongMember.value, + onFinished: () => Navigator.pop(context), + ); + }, + child: const Text('作成'), + ), + ); + } +} diff --git a/lib/view/widgets/trips/trip_belonging_list.dart b/lib/view/widgets/trips/trip_belonging_list.dart index 794a2f1a..fd834bfc 100644 --- a/lib/view/widgets/trips/trip_belonging_list.dart +++ b/lib/view/widgets/trips/trip_belonging_list.dart @@ -4,6 +4,7 @@ import 'package:trip_app_nativeapp/core/extensions/build_context.dart'; import 'package:trip_app_nativeapp/features/trips/controller/trip_belonging_controller.dart'; import 'package:trip_app_nativeapp/view/widgets/common/car_driving_loading.dart'; import 'package:trip_app_nativeapp/view/widgets/common/error_cat.dart'; +import 'package:trip_app_nativeapp/view/widgets/trips/add_trip_belonging_sheet.dart'; class TripBelongingList extends HookConsumerWidget { const TripBelongingList(this.tripId, {super.key}); @@ -11,9 +12,7 @@ class TripBelongingList extends HookConsumerWidget { final int tripId; @override Widget build(BuildContext context, WidgetRef ref) { - return ref - .watch(tripBelongingsControllerProvider(tripId: tripId)) - .when( + return ref.watch(tripBelongingsControllerProvider(tripId: tripId)).when( data: (belongings) { return Column( children: [ @@ -41,6 +40,17 @@ class TripBelongingList extends HookConsumerWidget { }, ), ), + Padding( + padding: const EdgeInsets.all(8), + child: FloatingActionButton( + onPressed: () => showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => AddTripBelongingSheet(tripId), + ), + child: const Icon(Icons.add, color: Colors.white), + ), + ), ], ); }, diff --git a/test/feature/trips/controller/trip_belonging_controller_test.dart b/test/feature/trips/controller/trip_belonging_controller_test.dart new file mode 100644 index 00000000..ef767de9 --- /dev/null +++ b/test/feature/trips/controller/trip_belonging_controller_test.dart @@ -0,0 +1,137 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:http_mock_adapter/http_mock_adapter.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:trip_app_nativeapp/core/http/api_client/api_destination.dart'; +import 'package:trip_app_nativeapp/core/http/api_client/dio/dio.dart'; +import 'package:trip_app_nativeapp/features/trips/controller/trip_belonging_controller.dart'; +import 'package:trip_app_nativeapp/features/trips/domain/entity/trip/trip_belonging.dart'; +import 'package:trip_app_nativeapp/features/trips/domain/entity/trip/value/trip_belonging_name.dart'; +import 'package:trip_app_nativeapp/features/trips/domain/entity/trip/value/trip_belonging_num.dart'; + +import '../../../mock/async_value_listener.dart'; + +Future main() async { + late ProviderContainer providerContainer; + + final dio = Dio(BaseOptions(validateStatus: (status) => true)); + final dioAdapter = DioAdapter(dio: dio); + + const testTripId = 1; + final testFetchBelongings = [ + AddedTripBelonging( + id: 1, + name: TripBelongingName(value: '持ち物1'), + numOf: TripBelongingNum(value: 5), + isShareAmongMember: true, + isChecked: false, + ), + AddedTripBelonging( + id: 2, + name: TripBelongingName(value: '持ち物2'), + numOf: TripBelongingNum(value: 10), + isShareAmongMember: true, + isChecked: true, + ), + ]; + + final testAddBelonging = AddedTripBelonging( + id: 3, + name: TripBelongingName(value: '持ち物3'), + numOf: TripBelongingNum(value: 10), + isShareAmongMember: false, + isChecked: false, + ); + + final asyncValueListener = + AsyncValueListener>>(); + + setUp(() { + dioAdapter.onGet( + '/trips/$testTripId/belongings', + (server) => server.reply( + 200, + { + 'data': [ + { + 'id': 1, + 'name': '持ち物1', + 'num_of': 5, + 'is_share_among_member': true, + 'is_checked': false, + }, + { + 'id': 2, + 'name': '持ち物2', + 'num_of': 10, + 'is_share_among_member': true, + 'is_checked': true, + } + ], + }, + ), + ); + providerContainer = ProviderContainer( + overrides: [ + dioProvider(ApiDestination.privateTripAppV1).overrideWithValue(dio), + ], + )..listen( + tripBelongingsControllerProvider(tripId: testTripId), + asyncValueListener.call, + fireImmediately: true, + ); + }); + + group('コンストラクター', () { + test('正常系', () async { + final result = await providerContainer + .read(tripBelongingsControllerProvider(tripId: testTripId).future); + + expect(result, testFetchBelongings); + verify( + () => asyncValueListener( + null, + const AsyncLoading>(), + ), + ); + }); + }); + + group('addBelonging', () { + test('正常系', () async { + dioAdapter.onPost( + '/trips/$testTripId/belongings', + data: { + 'name': testAddBelonging.name.value, + 'num_of': testAddBelonging.numOf.value, + 'is_share_among_member': testAddBelonging.isShareAmongMember, + }, + (server) => server.reply( + 201, + { + 'data': { + 'id': 3, + 'name': testAddBelonging.name.value, + 'num_of': testAddBelonging.numOf.value, + 'is_share_among_member': testAddBelonging.isShareAmongMember, + 'is_checked': false, + }, + }, + ), + ); + await providerContainer + .read(tripBelongingsControllerProvider(tripId: testTripId).notifier) + .addBelonging( + tripId: testTripId, + name: testAddBelonging.name.value, + numOf: testAddBelonging.numOf.value, + isShareAmongMember: testAddBelonging.isShareAmongMember, + ); + final result = await providerContainer.read( + tripBelongingsControllerProvider(tripId: testTripId).future, + ); + expect(result, [testAddBelonging, ...testFetchBelongings]); + }); + }); +}