diff --git a/lib/models/content_model.dart b/lib/models/content_model.dart index c2dbe3a..a691390 100644 --- a/lib/models/content_model.dart +++ b/lib/models/content_model.dart @@ -10,6 +10,7 @@ class ContentModel with _$ContentModel { factory ContentModel({ required String contentId, required String contentImageUrl, + String? contentUrl, String? contentMemo, required String contentName, required List contentHashTag, diff --git a/lib/navigations/main_bottom_tab.dart b/lib/navigations/main_bottom_tab.dart index 8d80c69..bd57374 100644 --- a/lib/navigations/main_bottom_tab.dart +++ b/lib/navigations/main_bottom_tab.dart @@ -1,19 +1,109 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:moa_app/constants/app_constants.dart'; import 'package:moa_app/constants/color_constants.dart'; import 'package:moa_app/constants/file_constants.dart'; +import 'package:moa_app/screens/add_content/folder_select.dart'; import 'package:moa_app/utils/my_platform.dart'; import 'package:moa_app/utils/router_provider.dart'; +import 'package:receive_sharing_intent/receive_sharing_intent.dart'; -class MainBottomTab extends HookWidget { +class MainBottomTab extends HookConsumerWidget { const MainBottomTab({super.key, required this.child}); final Widget child; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { var currentIndex = useState(0); bool keyboardIsOpen = MediaQuery.of(context).viewInsets.bottom == 0; + var lifeCycle = useAppLifecycleState(); + + void navigateToShareMedia( + BuildContext context, List value) { + if (value.isNotEmpty) { + var newFiles = []; + for (var element in value) { + newFiles.add(File( + Platform.isIOS + ? element.type == SharedMediaType.FILE + ? element.path + .toString() + .replaceAll(AppConstants.replaceableText, '') + : element.path + : element.path, + )); + } + + context.push( + '${GoRoutes.fileSharing.fullPath}/$newFiles', + ); + } + } + + var receiveUrl = useState(''); + + void navigateToShareText(BuildContext context, String? value) { + if (value != null && value.toString().isNotEmpty && context.mounted) { + receiveUrl.value = value; + } + } + + //All listeners to listen Sharing media files & text + void listenShareMediaFiles(BuildContext context) { + // For sharing images coming from outside the app + // while the app is in the memory + ReceiveSharingIntent.getMediaStream().listen((value) { + navigateToShareMedia(context, value); + }, onError: (err) { + debugPrint('$err'); + }); + + // For sharing images coming from outside the app while the app is closed + ReceiveSharingIntent.getInitialMedia().then((value) { + navigateToShareMedia(context, value); + }); + + // For sharing or opening urls/text coming from outside the app while the app is in the memory + ReceiveSharingIntent.getTextStream().listen((value) { + navigateToShareText(context, value); + }, onError: (err) { + debugPrint('$err'); + }); + + // For sharing or opening urls/text coming from outside the app while the app is closed + ReceiveSharingIntent.getInitialText().then((value) { + navigateToShareText(context, value); + }); + } + + useEffect(() { + if (!kIsWeb) { + SchedulerBinding.instance.addPostFrameCallback((timeStamp) { + listenShareMediaFiles(context); + }); + } + return null; + }, []); + + useEffect(() { + /// 외부에서 url 공유로 들어왔을경우 폴더선택화면으로 이동 + if (receiveUrl.value.isNotEmpty && + lifeCycle == AppLifecycleState.resumed) { + context.go( + GoRoutes.folderSelect.fullPath, + extra: FolderSelect(receiveUrl: receiveUrl.value), + ); + receiveUrl.value = ''; + return; + } + return null; + }, [lifeCycle]); void tap(BuildContext context, int index) { if (index == currentIndex.value) { diff --git a/lib/providers/item_provider.dart b/lib/providers/folder_view_provider.dart similarity index 54% rename from lib/providers/item_provider.dart rename to lib/providers/folder_view_provider.dart index 079eab5..95e8ba2 100644 --- a/lib/providers/item_provider.dart +++ b/lib/providers/folder_view_provider.dart @@ -1,14 +1,15 @@ import 'dart:async'; import 'package:moa_app/models/folder_model.dart'; +import 'package:moa_app/repositories/folder_repository.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; -part 'item_provider.g.dart'; +part 'folder_view_provider.g.dart'; /// AsyncNotifierProvider // @Riverpod(keepAlive: true) @riverpod -class AsyncItems extends _$AsyncItems { +class FolderView extends _$FolderView { Future> fetchItem() async { // get the [KeepAliveLink] var link = ref.keepAlive(); @@ -32,45 +33,12 @@ class AsyncItems extends _$AsyncItems { timer?.cancel(); }); - // var itemData = ItemRepository.instance.getItems(); - return []; + var data = FolderRepository.instance.getFolderList(); + return data; } @override Future> build() async { return fetchItem(); } - - Future addItems({ - required FolderModel item, - }) async { - state = const AsyncValue.loading(); - - state = await AsyncValue.guard(() async { - // await ItemRepository.instance.addItem(item: item); - return fetchItem(); - }); - } - - Future removeItems({ - required int id, - }) async { - state = const AsyncValue.loading(); - - state = await AsyncValue.guard(() async { - // await ItemRepository.instance.removeItem(id: id); - return fetchItem(); - }); - } - - Future updateItems({ - required FolderModel item, - }) async { - state = const AsyncValue.loading(); - - state = await AsyncValue.guard(() async { - // await ItemRepository.instance.updateItem(item: item); - return fetchItem(); - }); - } } diff --git a/lib/providers/hashtag_view_provider.dart b/lib/providers/hashtag_view_provider.dart new file mode 100644 index 0000000..e567d6d --- /dev/null +++ b/lib/providers/hashtag_view_provider.dart @@ -0,0 +1,44 @@ +import 'dart:async'; + +import 'package:moa_app/models/content_model.dart'; +import 'package:moa_app/repositories/hashtag_repository.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'hashtag_view_provider.g.dart'; + +/// AsyncNotifierProvider +// @Riverpod(keepAlive: true) +@riverpod +class HashtagView extends _$HashtagView { + Future<(List, int)> fetchItem() async { + // get the [KeepAliveLink] + var link = ref.keepAlive(); + // a timer to be used by the callbacks below + Timer? timer; + // An object from package:dio that allows cancelling http requests + // When the provider is destroyed, cancel the http request and the timer + ref.onDispose(() { + timer?.cancel(); + }); + // When the last listener is removed, start a timer to dispose the cached data + ref.onCancel(() { + // start a 30 second timer + timer = Timer(const Duration(seconds: 30), () { + // dispose on timeout + link.close(); + }); + }); + // If the provider is listened again after it was paused, cancel the timer + ref.onResume(() { + timer?.cancel(); + }); + + var data = HashtagRepository.instance.getHashtagView(); + return data; + } + + @override + Future<(List, int)> build() async { + return fetchItem(); + } +} diff --git a/lib/repositories/content_repository.dart b/lib/repositories/content_repository.dart index 19743ec..9be008e 100644 --- a/lib/repositories/content_repository.dart +++ b/lib/repositories/content_repository.dart @@ -26,6 +26,7 @@ class ContentRepository implements IContentRepository { var token = await TokenRepository.instance.getToken(); if (contentType.name == AddContentType.image.name) { + /// 이미지 방식 await dio.post( '/api/v1/content/create', data: { @@ -47,6 +48,24 @@ class ContentRepository implements IContentRepository { if (contentType.name == AddContentType.url.name) { /// 링크 방식 + await dio.post( + '/api/v1/content/create', + data: { + 'folderId': content.contentId, + 'name': content.contentName, + 'memo': content.contentMemo, + 'hashTag': hashTagStringList, + 'contentType': 'URL', + 'url': content.contentUrl, + // 'originalFileName': '${content.contentName}.png', + // 'image': 'image/png:base64:${content.contentImageUrl}', + }, + options: Options( + headers: { + 'Authorization': 'Bearer $token', + }, + ), + ); } } } diff --git a/lib/screens/add_content/add_link_content.dart b/lib/screens/add_content/add_link_content.dart index 0a776e0..13fda0b 100644 --- a/lib/screens/add_content/add_link_content.dart +++ b/lib/screens/add_content/add_link_content.dart @@ -1,8 +1,13 @@ +import 'dart:io'; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:html/parser.dart' as html_parser; +import 'package:http/http.dart' as http; +import 'package:image_picker/image_picker.dart'; import 'package:moa_app/constants/color_constants.dart'; import 'package:moa_app/constants/file_constants.dart'; import 'package:moa_app/constants/font_constants.dart'; @@ -11,6 +16,7 @@ import 'package:moa_app/providers/hashtag_provider.dart'; import 'package:moa_app/repositories/content_repository.dart'; import 'package:moa_app/screens/add_content/add_image_content.dart'; import 'package:moa_app/screens/add_content/widgets/add_content_bottom.dart'; +import 'package:moa_app/utils/utils.dart'; import 'package:moa_app/widgets/app_bar.dart'; import 'package:moa_app/widgets/button.dart'; import 'package:moa_app/widgets/edit_text.dart'; @@ -18,12 +24,18 @@ import 'package:moa_app/widgets/moa_widgets/error_text.dart'; import 'package:moa_app/widgets/snackbar.dart'; class AddLinkContent extends HookConsumerWidget { - const AddLinkContent({super.key, required this.folderId}); + const AddLinkContent({super.key, required this.folderId, this.receiveUrl}); final String folderId; + final String? receiveUrl; @override Widget build(BuildContext context, WidgetRef ref) { var hashtagAsync = ref.watch(hashtagProvider); + var picker = ImagePicker(); + var imageFile = useState(null); + var receiveImage = useState(null); + // var defaultImageList = useState>([]); + var title = useState(''); var link = useState(''); var memo = useState(''); @@ -31,10 +43,32 @@ class AddLinkContent extends HookConsumerWidget { var hashtagController = useTextEditingController(); var selectedTagList = useState>([]); + var imageError = useState(''); var titleError = useState(''); var tagError = useState(''); var linkError = useState(''); + var linkController = useTextEditingController(); + var titleController = useTextEditingController(); + var memoController = useTextEditingController(); + + void pickImage({required ImageSource source, required int index}) async { + if (index == 0) { + var pickedFile = await picker.pickImage(source: source); + imageFile.value = pickedFile; + return; + } + + // todo 기본제공 이미지 10종 리스트에서 추가 + // imageFile.value = defaultImageList.value[index - 1]; + + // todo 대표 이미지 미지정시 하트들고있는 모아 이미지로 대체 + + if (imageFile.value != null) { + imageError.value = ''; + } + } + void completeAddContent() async { if (link.value.isEmpty || title.value.isEmpty || @@ -54,6 +88,8 @@ class AddLinkContent extends HookConsumerWidget { return; } + String base64Image = await xFileToBase64(imageFile.value!); + var selectTag = []; selectedTagList.value.map((element) { if (element.isSelected) { @@ -65,12 +101,13 @@ class AddLinkContent extends HookConsumerWidget { try { await ContentRepository.instance.addContent( - contentType: AddContentType.image, + contentType: AddContentType.url, content: ContentModel( contentId: folderId, + contentUrl: link.value, contentName: title.value, contentHashTag: [], - contentImageUrl: '', + contentImageUrl: base64Image, contentMemo: memo.value, ), hashTagStringList: hashTagStringList, @@ -132,6 +169,40 @@ class AddLinkContent extends HookConsumerWidget { return null; }, [hashtagAsync.isLoading]); + /// 공유로 받아온 url 크롤링 + void getCrawlUrl(String url) async { + await http.get(Uri.parse(url)).then((response) { + var document = html_parser.parse(response.body); + + var crawledTitle = document.head + ?.querySelector("meta[property='og:title']") + ?.attributes['content']; + var crawledDescription = document.head + ?.querySelector("meta[property='og:description']") + ?.attributes['content']; + var crawledImage = document.head + ?.querySelector("meta[property='og:image']") + ?.attributes['content']; + + link.value = url; + linkController.text = url; + title.value = crawledTitle ?? ''; + titleController.text = crawledTitle ?? ''; + memo.value = crawledDescription ?? ''; + memoController.text = crawledDescription ?? ''; + receiveImage.value = crawledImage ?? ''; + // imageFile.value = XFile(crawledImage ?? ''); + }); + } + + useEffect(() { + if (receiveUrl != null) { + // todo 유효한 url인지 체크필요 + getCrawlUrl(receiveUrl!); + } + return null; + }, []); + return Scaffold( appBar: const AppBarBack( isBottomBorderDisplayed: false, @@ -154,6 +225,7 @@ class AddLinkContent extends HookConsumerWidget { ), const SizedBox(height: 5), EditText( + controller: linkController, onChanged: onChangedLink, hintText: '링크를 입력하세요.', ), @@ -161,31 +233,25 @@ class AddLinkContent extends HookConsumerWidget { errorText: linkError.value, errorValidate: linkError.value.isNotEmpty, ), - Row( - children: [ - const Spacer(), - Text( - '${link.value.length}/30', - style: TextStyle( - color: title.value.length > 30 - ? AppColors.danger - : AppColors.blackColor.withOpacity(0.3), - fontSize: 12, - fontFamily: FontConstants.pretendard), - ), - ], - ), const SizedBox(height: 25), const Text( '대표 이미지', style: H4TextStyle(), ), const SizedBox(height: 5), + // receiveImage.value != null + // ? Image.network( + // receiveImage.value!, + // width: double.infinity, + // height: 200, + // fit: BoxFit.cover, + // ) + // : const SizedBox(), SizedBox( width: double.infinity, height: 85, child: ListView.builder( - itemCount: 5, + itemCount: 11, scrollDirection: Axis.horizontal, itemBuilder: (context, index) { return Padding( @@ -197,22 +263,45 @@ class AddLinkContent extends HookConsumerWidget { borderRadius: const BorderRadius.all( Radius.circular(15), ), + border: Border.all( + color: AppColors.grayBackground, + width: 0.5, + ), color: AppColors.textInputBackground, - image: - DecorationImage(image: Assets.moaBannerImg), + // image: + // DecorationImage(image: Assets.moaBannerImg), ), child: InkWell( - onTap: () {}, + onTap: () => pickImage( + source: ImageSource.gallery, index: index), borderRadius: const BorderRadius.all( Radius.circular(15), ), - child: Center( - child: Image( - width: 15, - height: 15, - image: Assets.circlePlus, - ), - ), + child: index == 0 + ? imageFile.value != null + ? Container( + decoration: BoxDecoration( + borderRadius: + BorderRadius.circular(15), + border: Border.all( + color: AppColors.grayBackground, + width: 0.5, + ), + image: DecorationImage( + fit: BoxFit.cover, + image: FileImage( + File(imageFile.value!.path)), + ), + ), + ) + : Center( + child: Image( + width: 15, + height: 15, + image: Assets.circlePlus, + ), + ) + : const SizedBox(), ), ), ); @@ -221,6 +310,8 @@ class AddLinkContent extends HookConsumerWidget { ), AddContentBottom( onChangedTitle: onChangedTitle, + titleController: titleController, + memoController: memoController, addHashtag: addHashtag, hashtagController: hashtagController, onChangedHashtag: onChangedHashtag, diff --git a/lib/screens/add_content/folder_select.dart b/lib/screens/add_content/folder_select.dart index 57638a2..49d740c 100644 --- a/lib/screens/add_content/folder_select.dart +++ b/lib/screens/add_content/folder_select.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:moa_app/constants/app_constants.dart'; import 'package:moa_app/constants/file_constants.dart'; import 'package:moa_app/constants/font_constants.dart'; @@ -13,12 +13,23 @@ import 'package:moa_app/widgets/alert_dialog.dart'; import 'package:moa_app/widgets/app_bar.dart'; import 'package:moa_app/widgets/loading_indicator.dart'; -class FolderSelect extends HookWidget { - const FolderSelect({super.key}); +class FolderSelect extends HookConsumerWidget { + const FolderSelect({super.key, this.receiveUrl}); + final String? receiveUrl; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { void selectFolder({required int index, required String folderId}) { + if (receiveUrl != null) { + context.push( + '${GoRoutes.folderSelect.fullPath}/${GoRoutes.addLinkContent.path}', + extra: AddLinkContent( + folderId: folderId, + receiveUrl: receiveUrl, + ), + ); + return; + } alertDialog.select( context, title: '어떤 취향으로 저장하시겠어요?', @@ -41,9 +52,17 @@ class FolderSelect extends HookWidget { } return Scaffold( - appBar: const AppBarBack( + appBar: AppBarBack( isBottomBorderDisplayed: false, title: '폴더 선택', + onPressedBack: () { + /// 외부 공유url 받아서 들어온 경우 뒤로가기가 안되기 떄문에 홈으로 이동 + if (receiveUrl != null) { + context.go('/'); + return; + } + context.pop(); + }, ), body: SafeArea( child: Padding( diff --git a/lib/screens/add_content/widgets/add_content_bottom.dart b/lib/screens/add_content/widgets/add_content_bottom.dart index 3d69857..a70da95 100644 --- a/lib/screens/add_content/widgets/add_content_bottom.dart +++ b/lib/screens/add_content/widgets/add_content_bottom.dart @@ -21,6 +21,8 @@ class AddContentBottom extends HookWidget { required this.tagError, required this.onChangedMemo, required this.memo, + this.titleController, + this.memoController, }); final ValueChanged onChangedTitle; final ValueNotifier titleError; @@ -32,6 +34,8 @@ class AddContentBottom extends HookWidget { final ValueNotifier tagError; final ValueChanged onChangedMemo; final ValueNotifier memo; + final TextEditingController? titleController; + final TextEditingController? memoController; @override Widget build(BuildContext context) { @@ -45,6 +49,7 @@ class AddContentBottom extends HookWidget { ), const SizedBox(height: 5), EditText( + controller: titleController, maxLength: 30, onChanged: onChangedTitle, hintText: '1~30자로 입력할 수 있어요.', @@ -161,6 +166,7 @@ class AddContentBottom extends HookWidget { Stack( children: [ EditText( + controller: memoController, maxLines: 4, maxLength: 100, height: 135, diff --git a/lib/screens/home/home.dart b/lib/screens/home/home.dart index d965f0b..731c5c9 100644 --- a/lib/screens/home/home.dart +++ b/lib/screens/home/home.dart @@ -10,7 +10,7 @@ import 'package:moa_app/models/content_model.dart'; import 'package:moa_app/models/folder_model.dart'; import 'package:moa_app/models/user_model.dart'; import 'package:moa_app/providers/button_click_provider.dart'; -import 'package:moa_app/repositories/folder_repository.dart'; +import 'package:moa_app/providers/folder_view_provider.dart'; import 'package:moa_app/repositories/hashtag_repository.dart'; import 'package:moa_app/repositories/user_repository.dart'; import 'package:moa_app/screens/home/tab_view/folder_tab_view.dart'; @@ -23,6 +23,8 @@ class Home extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + var folderAsync = ref.watch(folderViewProvider.notifier); + // var hashtagAsync = ref.watch(hashtagViewProvider.notifier); var isClick = ref.watch(buttonClickStateProvider); var tabIdx = useState(0); TabController tabController = useTabController(initialLength: 2); @@ -109,11 +111,13 @@ class Home extends HookConsumerWidget { controller: tabController, children: [ TabViewItem( + futureList: folderAsync.future, uniqueKey: const Key('folderTab'), folderCount: folderCount, contentCount: contentCount, ), TabViewItem( + futureList: Future.value([]), uniqueKey: const Key('hashtagTab'), folderCount: folderCount, contentCount: contentCount, @@ -263,8 +267,9 @@ class PersistentTabBar extends SliverPersistentHeaderDelegate { } class FolderSource extends LoadingMoreBase { - FolderSource({required this.folderCount}); + FolderSource({required this.folderCount, required this.futureList}); final ValueNotifier folderCount; + final Future> futureList; var count = 0; int pageIndex = 1; bool _hasMore = false; @@ -288,9 +293,8 @@ class FolderSource extends LoadingMoreBase { @override Future loadData([bool isloadMoreAction = false]) async { bool isSuccess = false; - try { - var list = await FolderRepository.instance.getFolderList(); + var list = await futureList; folderCount.value = list.length; for (FolderModel folder in list) { @@ -362,10 +366,12 @@ class TabViewItem extends StatefulWidget { const TabViewItem({ super.key, required this.uniqueKey, + required this.futureList, required this.folderCount, required this.contentCount, }); final Key uniqueKey; + final Future> futureList; final ValueNotifier folderCount; final ValueNotifier contentCount; @@ -376,6 +382,7 @@ class TabViewItem extends StatefulWidget { class TabViewItemState extends State with AutomaticKeepAliveClientMixin { late final FolderSource folderSource = FolderSource( + futureList: widget.futureList, folderCount: widget.folderCount, ); late final HashtagSource hashtagSource = HashtagSource( diff --git a/lib/utils/router_provider.dart b/lib/utils/router_provider.dart index 7512de3..e822c07 100644 --- a/lib/utils/router_provider.dart +++ b/lib/utils/router_provider.dart @@ -129,15 +129,9 @@ final routeProvider = Provider( return GoRoutes.greeting.fullPath; } var token = ref.read(tokenStateProvider); - if (token.value != null) { - var user = await UserRepository.instance.getUser(); - /// 닉네임 설정 안했으면 닉네임 설정 페이지로 - if (user?.nickname == null) { - return GoRoutes.inputName.fullPath; - } - } - if (token.value == null && + var user = await UserRepository.instance.getUser(); + if ((token.value == null || user?.nickname == null) && state.matchedLocation != GoRoutes.signIn.fullPath) { return GoRoutes.signIn.fullPath; } @@ -258,11 +252,23 @@ final routeProvider = Provider( parentNavigatorKey: _rootNavigatorKey, name: GoRoutes.folderSelect.name, path: GoRoutes.folderSelect.fullPath, - pageBuilder: (context, state) => buildIosPageTransitions( + pageBuilder: (context, state) { + if (state.extra != null) { + var folderSelect = state.extra as FolderSelect; + return buildIosPageTransitions( context: context, state: state, - child: const FolderSelect(), - ), + child: FolderSelect( + receiveUrl: folderSelect.receiveUrl, + ), + ); + } + return buildIosPageTransitions( + context: context, + state: state, + child: const FolderSelect(), + ); + }, routes: [ GoRoute( parentNavigatorKey: _rootNavigatorKey, @@ -289,6 +295,7 @@ final routeProvider = Provider( state: state, child: AddLinkContent( folderId: addLink.folderId, + receiveUrl: addLink.receiveUrl, ), ); }, diff --git a/lib/widgets/app_bar.dart b/lib/widgets/app_bar.dart index 8fbf8c9..e2015b1 100644 --- a/lib/widgets/app_bar.dart +++ b/lib/widgets/app_bar.dart @@ -13,12 +13,14 @@ class AppBarBack extends StatelessWidget implements PreferredSizeWidget { this.actions, this.isBottomBorderDisplayed = true, this.bottomBorderStyle = const BottomBorderStyle(), + this.onPressedBack, }) : super(key: key); final String? title; final Widget? leading; final List? actions; final bool isBottomBorderDisplayed; final BottomBorderStyle bottomBorderStyle; + final VoidCallback? onPressedBack; @override Widget build(BuildContext context) { @@ -36,6 +38,10 @@ class AppBarBack extends StatelessWidget implements PreferredSizeWidget { image: Assets.arrowBack, ), onPressed: () { + if (onPressedBack != null) { + onPressedBack!(); + return; + } context.pop(); }, ),