diff --git a/app/lib/pages/apps/app_detail.dart b/app/lib/pages/apps/app_detail.dart index dcc8d64de..7d3d663c0 100644 --- a/app/lib/pages/apps/app_detail.dart +++ b/app/lib/pages/apps/app_detail.dart @@ -444,7 +444,7 @@ class _AppDetailPageState extends State { child: Container( width: double.infinity, padding: const EdgeInsets.all(16.0), - margin: const EdgeInsets.only(left: 6.0, right: 6.0, top: 12, bottom: 6), + margin: const EdgeInsets.only(left: 8.0, right: 8.0, top: 12, bottom: 6), decoration: BoxDecoration( color: Colors.grey.shade900, borderRadius: BorderRadius.circular(16.0), diff --git a/app/lib/pages/apps/explore_install_page.dart b/app/lib/pages/apps/explore_install_page.dart new file mode 100644 index 000000000..f76b474bc --- /dev/null +++ b/app/lib/pages/apps/explore_install_page.dart @@ -0,0 +1,275 @@ +import 'package:flutter/material.dart'; +import 'package:friend_private/backend/schema/app.dart'; +import 'package:friend_private/pages/apps/providers/add_app_provider.dart'; +import 'package:friend_private/pages/apps/widgets/app_section_card.dart'; +import 'package:friend_private/pages/apps/widgets/filter_sheet.dart'; +import 'package:friend_private/pages/apps/list_item.dart'; +import 'package:friend_private/providers/app_provider.dart'; +import 'package:friend_private/providers/home_provider.dart'; +import 'package:provider/provider.dart'; + +String filterValueToString(dynamic value) { + if (value.runtimeType == String) { + return value; + } else if (value.runtimeType == Category) { + return (value as Category).title; + } else if (value.runtimeType == AppCapability) { + return (value as AppCapability).title; + } + return ''; +} + +class ExploreInstallPage extends StatefulWidget { + const ExploreInstallPage({super.key}); + + @override + State createState() => _ExploreInstallPageState(); +} + +class _ExploreInstallPageState extends State with AutomaticKeepAliveClientMixin { + late TextEditingController searchController; + + @override + void initState() { + searchController = TextEditingController(); + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().init(); + }); + super.initState(); + } + + @override + void dispose() { + searchController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + super.build(context); + return Consumer(builder: (context, provider, child) { + return CustomScrollView( + slivers: [ + const SliverToBoxAdapter(child: SizedBox(height: 18)), + SliverToBoxAdapter( + child: SizedBox( + height: 50, + child: Row( + children: [ + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: ChoiceChip( + label: Row( + children: [ + const Icon( + Icons.filter_list_alt, + size: 15, + ), + const SizedBox(width: 4), + const Padding( + padding: EdgeInsets.symmetric(vertical: 6.5), + child: Text( + 'Filter ', + style: TextStyle(fontSize: 16), + ), + ), + provider.isFilterActive() ? Text("(${provider.filters.length})") : const SizedBox.shrink(), + ], + ), + selected: false, + showCheckmark: true, + backgroundColor: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + onSelected: (bool selected) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => const FilterBottomSheet(), + ).whenComplete(() { + context.read().filterApps(); + }); + }, + ), + ), + const SizedBox( + width: 12, + ), + provider.isFilterActive() + ? Expanded( + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemBuilder: (ctx, idx) { + return ChoiceChip( + labelPadding: const EdgeInsets.only(left: 8), + label: Row( + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 6.5), + child: Text( + filterValueToString(provider.filters.values.elementAt(idx)), + style: const TextStyle(fontSize: 16), + ), + ), + const SizedBox(width: 6), + const Icon( + Icons.close, + size: 15, + ), + ], + ), + selected: false, + showCheckmark: true, + backgroundColor: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + onSelected: (bool selected) { + provider.removeFilter(provider.filters.keys.elementAt(idx)); + }, + ); + }, + separatorBuilder: (ctx, idx) { + return const SizedBox( + width: 10, + ); + }, + itemCount: provider.filters.length, + )) + : SizedBox( + width: MediaQuery.sizeOf(context).width * 0.72, + height: 40, + child: TextFormField( + controller: searchController, + focusNode: context.read().appsSearchFieldFocusNode, + onChanged: (value) { + provider.searchApps(value); + }, + decoration: InputDecoration( + hintText: 'Search apps', + hintStyle: const TextStyle(color: Colors.white), + filled: true, + fillColor: Colors.grey[800], + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + suffixIcon: provider.isSearchActive() + ? GestureDetector( + onTap: () { + context.read().appsSearchFieldFocusNode.unfocus(); + provider.searchApps(''); + searchController.clear(); + }, + child: const Icon( + Icons.close, + color: Colors.white, + ), + ) + : null, + contentPadding: const EdgeInsets.symmetric(horizontal: 12), + ), + style: const TextStyle(color: Colors.white), + ), + ) + ], + ), + ), + ), + provider.isFilterActive() || provider.isSearchActive() + ? const SliverToBoxAdapter(child: SizedBox.shrink()) + : const SliverToBoxAdapter( + child: SizedBox( + height: 10, + )), + !provider.isFilterActive() && !provider.isSearchActive() + ? const SliverToBoxAdapter(child: SizedBox.shrink()) + : Consumer( + builder: (context, provider, child) { + if (provider.filteredApps.isEmpty) { + return SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.only(top: MediaQuery.sizeOf(context).height * 0.28), + child: const Text( + 'No apps found', + style: TextStyle(fontSize: 18), + textAlign: TextAlign.center, + ), + ), + ); + } + return SliverToBoxAdapter( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListView.separated( + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + itemBuilder: (ctx, idx) { + return AppListItem( + app: provider.filteredApps[idx], + index: + provider.apps.indexWhere((element) => element.id == provider.filteredApps[idx].id), + ); + }, + separatorBuilder: (ctx, idx) { + return const SizedBox(height: 8); + }, + itemCount: provider.filteredApps.length, + ), + const SizedBox(height: 64), + ], + ), + ); + }, + ), + !provider.isFilterActive() && !provider.isSearchActive() + ? SliverToBoxAdapter( + child: AppSectionCard( + title: 'Popular Apps', + apps: context + .read() + .apps + .where((p) => (p.installs > 50 && (p.ratingAvg ?? 0.0) > 4.0)) + .toList(), + ), + ) + : const SliverToBoxAdapter(child: SizedBox.shrink()), + !provider.isFilterActive() && !provider.isSearchActive() + ? const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.only(left: 12.0, top: 18, bottom: 0), + child: Text('All Apps', style: TextStyle(fontSize: 18)), + ), + ) + : const SliverToBoxAdapter(child: SizedBox.shrink()), + !provider.isFilterActive() && !provider.isSearchActive() + ? Selector>( + selector: (context, provider) => provider.apps, + builder: (context, memoryIntegrationApps, child) { + return SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + return AppListItem( + app: memoryIntegrationApps[index], + index: index, + ); + }, + childCount: memoryIntegrationApps.length, + ), + ); + }, + ) + : const SliverToBoxAdapter(child: SizedBox.shrink()), + ], + ); + }); + } + + @override + bool get wantKeepAlive => true; +} diff --git a/app/lib/pages/apps/list_item.dart b/app/lib/pages/apps/list_item.dart index c9ff57df3..88b95ef4c 100644 --- a/app/lib/pages/apps/list_item.dart +++ b/app/lib/pages/apps/list_item.dart @@ -13,8 +13,9 @@ import 'app_detail.dart'; class AppListItem extends StatelessWidget { final App app; final int index; + final bool showPrivateIcon; - const AppListItem({super.key, required this.app, required this.index}); + const AppListItem({super.key, required this.app, required this.index, this.showPrivateIcon = true}); @override Widget build(BuildContext context) { @@ -58,7 +59,7 @@ class AppListItem extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - app.name + (app.private ? ' (private)' : ''), + app.name.decodeString, maxLines: 1, style: const TextStyle(fontWeight: FontWeight.w600, color: Colors.white, fontSize: 16), ), @@ -71,40 +72,53 @@ class AppListItem extends StatelessWidget { style: const TextStyle(color: Colors.grey, fontSize: 14), ), ), - app.ratingAvg != null || app.installs > 0 - ? Padding( - padding: const EdgeInsets.only(top: 8), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.start, - children: [ - app.ratingAvg != null - ? Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text(app.getRatingAvg()!), - const SizedBox(width: 4), - const Icon(Icons.star, color: Colors.deepPurple, size: 16), - const SizedBox(width: 4), - Text('(${app.ratingCount})'), - const SizedBox(width: 16), - ], - ) - : const SizedBox(), - app.installs > 0 - ? Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Icon(Icons.download_rounded, size: 16, color: Colors.grey.shade300), - const SizedBox(width: 4), - Text('${app.installs}'), - ], - ) - : Container(), - ], - ), - ) - : Container(), + Row( + children: [ + app.ratingAvg != null || app.installs > 0 + ? Padding( + padding: const EdgeInsets.only(top: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + app.ratingAvg != null + ? Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text(app.getRatingAvg()!), + const SizedBox(width: 4), + const Icon(Icons.star, color: Colors.deepPurple, size: 16), + const SizedBox(width: 4), + Text('(${app.ratingCount})'), + const SizedBox(width: 16), + ], + ) + : const SizedBox(), + ], + ), + ) + : Container(), + app.private && showPrivateIcon + ? Row( + children: [ + (app.ratingAvg != null || app.installs > 0) + ? const SizedBox(width: 16) + : const SizedBox(), + const Padding( + padding: EdgeInsets.only(top: 8), + child: Row( + children: [ + Icon(Icons.lock, color: Colors.grey, size: 16), + SizedBox(width: 4), + Text('Private', style: TextStyle(color: Colors.grey, fontSize: 14)), + ], + ), + ), + ], + ) + : const SizedBox(), + ], + ), ], ), ), diff --git a/app/lib/pages/apps/manage_create_page.dart b/app/lib/pages/apps/manage_create_page.dart new file mode 100644 index 000000000..6247bd6b2 --- /dev/null +++ b/app/lib/pages/apps/manage_create_page.dart @@ -0,0 +1,168 @@ +import 'package:flutter/material.dart'; +import 'package:friend_private/backend/schema/app.dart'; +import 'package:friend_private/pages/apps/add_app.dart'; +import 'package:friend_private/pages/apps/list_item.dart'; +import 'package:friend_private/providers/app_provider.dart'; +import 'package:friend_private/utils/analytics/mixpanel.dart'; +import 'package:friend_private/utils/other/temp.dart'; +import 'package:provider/provider.dart'; + +class ManageCreatePage extends StatelessWidget { + const ManageCreatePage({super.key}); + + @override + Widget build(BuildContext context) { + return Consumer(builder: (context, provider, child) { + return CustomScrollView( + slivers: [ + const SliverToBoxAdapter(child: SizedBox(height: 18)), + SliverToBoxAdapter( + child: Row( + children: [ + const SizedBox(width: 16), + ChoiceChip( + label: const Text('Installed Apps'), + selected: provider.installedAppsOptionSelected, + showCheckmark: true, + backgroundColor: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + onSelected: (bool selected) { + provider.updateInstalledAppsOptionSelected(true); + }, + ), + const SizedBox(width: 10), + ChoiceChip( + label: const Text('My Apps'), + selected: !provider.installedAppsOptionSelected, + showCheckmark: true, + backgroundColor: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + onSelected: (bool selected) { + provider.updateInstalledAppsOptionSelected(false); + }, + ), + ], + ), + ), + SliverToBoxAdapter( + child: AnimatedSwitcher( + transitionBuilder: (child, animation) => FadeTransition( + opacity: animation, + child: child, + ), + duration: const Duration(milliseconds: 500), + child: provider.installedAppsOptionSelected + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.only(left: 16.0), + child: Text('Apps (${provider.apps.where((a) => a.enabled).length})', + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w400)), + ), + Selector>( + selector: (context, provider) => provider.apps.where((p) => p.enabled).toList(), + builder: (context, memoryPromptApps, child) { + return ListView.builder( + itemCount: memoryPromptApps.length, + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + itemBuilder: (context, index) { + return AppListItem( + app: memoryPromptApps[index], + index: index, + ); + }, + ); + }, + ), + const SizedBox( + height: 50, + ), + ], + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 16), + GestureDetector( + onTap: () { + MixpanelManager().pageOpened('Submit App'); + routeToPage(context, const AddAppPage()); + }, + child: Container( + padding: const EdgeInsets.all(12.0), + margin: const EdgeInsets.only(left: 12.0, right: 12.0, top: 2, bottom: 24), + decoration: BoxDecoration( + color: Colors.grey.shade900, + borderRadius: BorderRadius.circular(16.0), + ), + child: const ListTile( + title: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.add, color: Colors.white), + SizedBox(width: 8), + Text( + 'Create and submit a new app', + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ), + provider.userPrivateApps.isEmpty + ? const SizedBox() + : Padding( + padding: const EdgeInsets.only(left: 16.0, bottom: 10), + child: Text('Private Apps (${provider.userPrivateApps.length})', + style: const TextStyle(fontSize: 18)), + ), + provider.userPrivateApps.isEmpty + ? const SizedBox() + : ListView.builder( + itemCount: provider.userPrivateApps.length, + shrinkWrap: true, + itemBuilder: (context, index) { + return AppListItem( + showPrivateIcon: false, + app: provider.userPrivateApps[index], + index: provider.apps.indexOf(provider.userPrivateApps[index]), + ); + }, + ), + provider.userPublicApps.isEmpty + ? const SizedBox() + : Padding( + padding: const EdgeInsets.only(left: 16.0, bottom: 10), + child: Text('Public Apps (${provider.userPublicApps.length})', + style: const TextStyle(fontSize: 18)), + ), + provider.userPublicApps.isEmpty + ? const SizedBox() + : ListView.builder( + itemCount: provider.userPublicApps.length, + shrinkWrap: true, + itemBuilder: (context, index) { + return AppListItem( + app: provider.userPublicApps[index], + index: provider.apps.indexOf(provider.userPublicApps[index]), + ); + }, + ), + ], + ), + ), + ), + ], + ); + }); + } +} diff --git a/app/lib/pages/apps/page.dart b/app/lib/pages/apps/page.dart index 75cde5161..237df96e2 100644 --- a/app/lib/pages/apps/page.dart +++ b/app/lib/pages/apps/page.dart @@ -1,38 +1,30 @@ import 'package:flutter/material.dart'; -import 'package:friend_private/backend/schema/app.dart'; -import 'package:friend_private/pages/apps/add_app.dart'; -import 'package:friend_private/pages/apps/list_item.dart'; +import 'package:friend_private/pages/apps/explore_install_page.dart'; +import 'package:friend_private/pages/apps/manage_create_page.dart'; import 'package:friend_private/pages/apps/providers/add_app_provider.dart'; -import 'package:friend_private/pages/chat/widgets/animated_mini_banner.dart'; import 'package:friend_private/providers/connectivity_provider.dart'; import 'package:friend_private/providers/app_provider.dart'; -import 'package:friend_private/utils/analytics/mixpanel.dart'; -import 'package:friend_private/utils/other/temp.dart'; -import 'package:friend_private/widgets/dialog.dart'; import 'package:provider/provider.dart'; class AppsPage extends StatefulWidget { final bool filterChatOnly; - const AppsPage({super.key, this.filterChatOnly = false}); @override State createState() => _AppsPageState(); } -class _AppsPageState extends State with AutomaticKeepAliveClientMixin { +class _AppsPageState extends State { @override void initState() { WidgetsBinding.instance.addPostFrameCallback((_) { - context.read().initialize(widget.filterChatOnly); - context.read().getAppCapabilities(); + context.read().getCategories(); }); super.initState(); } @override Widget build(BuildContext context) { - super.build(context); return Scaffold( backgroundColor: Theme.of(context).colorScheme.primary, appBar: widget.filterChatOnly @@ -44,160 +36,36 @@ class _AppsPageState extends State with AutomaticKeepAliveClientMixin elevation: 0, ) : null, - body: context.read().loading - ? const Center( - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - CircularProgressIndicator( - valueColor: AlwaysStoppedAnimation(Colors.white), - ), - SizedBox( - height: 14, - ), - Text( - 'Loading apps', - style: TextStyle(color: Colors.white), - ), - ], - ), - ) - : GestureDetector( - onTap: () => FocusScope.of(context).unfocus(), - child: DefaultTabController( - length: 2, - initialIndex: widget.filterChatOnly ? 1 : 0, - child: Column( - children: [ - TabBar( - indicatorSize: TabBarIndicatorSize.label, - isScrollable: false, - padding: EdgeInsets.zero, - indicatorPadding: EdgeInsets.zero, - labelStyle: Theme.of(context).textTheme.titleLarge!.copyWith(fontSize: 18), - indicatorColor: Colors.transparent, - tabs: const [Tab(text: 'Memories'), Tab(text: 'Chat')], - ), - InkWell( - onTap: () { - MixpanelManager().pageOpened('Submit App'); - routeToPage(context, const AddAppPage()); - }, - child: AnimatedMiniBanner( - showAppBar: true, - height: 10, - child: Container( - color: Colors.grey[800], - child: const Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text('Create your own app', style: TextStyle(color: Colors.white, fontSize: 16)), - ], - ), - )), - ), - Expanded( - child: TabBarView(children: [ - CustomScrollView( - slivers: [ - const EmptyAppsWidget(), - const SectionTitleWidget( - title: 'External Apps', - explainer: - 'When a memory gets created you can use these apps to send data to external apps like Notion, Zapier, and more.', - emoji: '🚀', - ), - Selector>( - selector: (context, provider) => - provider.apps.where((p) => p.worksExternally()).toList(), - builder: (context, memoryIntegrationApps, child) { - return SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - return AppListItem( - app: memoryIntegrationApps[index], - index: index, - ); - }, - childCount: memoryIntegrationApps.length, - ), - ); - }), - context.read().apps.isNotEmpty - ? SliverToBoxAdapter(child: Divider(color: Colors.grey.shade800, thickness: 1)) - : const SliverToBoxAdapter(child: SizedBox.shrink()), - const SectionTitleWidget( - title: 'Prompts', - explainer: - 'When a memory gets created you can use these apps to extract more information about each memory.', - emoji: '📝', - ), - Selector>( - selector: (context, provider) => - provider.apps.where((p) => p.worksWithMemories()).toList(), - builder: (context, memoryPromptApps, child) { - return SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - return AppListItem( - app: memoryPromptApps[index], - index: index, - ); - }, - childCount: memoryPromptApps.length, - ), - ); - }), - const SliverToBoxAdapter( - child: SizedBox( - height: 120, - ), - ), - ], - ), - CustomScrollView( - slivers: [ - const EmptyAppsWidget(), - const SectionTitleWidget( - title: 'Personalities', - explainer: 'Personalities for your chat.', - emoji: '🤖', - ), - Selector>( - selector: (context, provider) => - provider.apps.where((p) => p.worksWithChat()).toList(), - builder: (context, chatPromptApps, child) { - return SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - return AppListItem( - app: chatPromptApps[index], - index: index, - ); - }, - childCount: chatPromptApps.length, - ), - ); - }), - const SliverToBoxAdapter( - child: SizedBox( - height: 120, - ), - ), - ], - ), - ]), - ) - ], - )), + body: DefaultTabController( + length: 2, + initialIndex: 0, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TabBar( + indicatorSize: TabBarIndicatorSize.label, + isScrollable: true, + padding: EdgeInsets.zero, + indicatorPadding: EdgeInsets.zero, + labelStyle: Theme.of(context).textTheme.titleLarge!.copyWith(fontSize: 18), + indicatorColor: Colors.white, + tabs: const [ + Tab(text: 'Explore & Install'), + Tab(text: 'Manage & Create'), + ], ), + const Expanded( + child: TabBarView( + children: [ + ExploreInstallPage(), + ManageCreatePage(), + ], + )), + ], + ), + ), ); } - - @override - bool get wantKeepAlive => true; } class EmptyAppsWidget extends StatelessWidget { @@ -225,49 +93,3 @@ class EmptyAppsWidget extends StatelessWidget { }); } } - -class SectionTitleWidget extends StatelessWidget { - final String title; - final String emoji; - final String explainer; - const SectionTitleWidget({super.key, required this.title, required this.emoji, required this.explainer}); - - @override - Widget build(BuildContext context) { - return Consumer(builder: (context, provider, child) { - return provider.apps.isNotEmpty - ? SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.only(top: 32), - child: GestureDetector( - onTap: () { - showDialog( - context: context, - builder: (c) => getDialog( - context, - () => Navigator.pop(context), - () => Navigator.pop(context), - '$title $emoji', - explainer, - singleButton: true, - okButtonText: 'Got it!', - ), - ); - }, - child: Center( - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text(title, style: const TextStyle(color: Colors.white, fontSize: 18)), - const SizedBox(width: 12), - Text(emoji, style: const TextStyle(fontSize: 18)), - ], - ), - ), - ), - ), - ) - : const SliverToBoxAdapter(child: SizedBox.shrink()); - }); - } -} diff --git a/app/lib/pages/apps/providers/add_app_provider.dart b/app/lib/pages/apps/providers/add_app_provider.dart index f97f69f29..0b76b1591 100644 --- a/app/lib/pages/apps/providers/add_app_provider.dart +++ b/app/lib/pages/apps/providers/add_app_provider.dart @@ -136,11 +136,13 @@ class AddAppProvider extends ChangeNotifier { Future getCategories() async { categories = await getAppCategories(); + appProvider!.categories = categories; notifyListeners(); } Future getAppCapabilities() async { capabilities = await getAppCapabilitiesServer(); + appProvider!.capabilities = capabilities; notifyListeners(); } diff --git a/app/lib/pages/apps/widgets/app_section_card.dart b/app/lib/pages/apps/widgets/app_section_card.dart new file mode 100644 index 000000000..c17ac4ec7 --- /dev/null +++ b/app/lib/pages/apps/widgets/app_section_card.dart @@ -0,0 +1,153 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:friend_private/backend/schema/app.dart'; +import 'package:friend_private/pages/apps/app_detail.dart'; +import 'package:friend_private/providers/app_provider.dart'; +import 'package:friend_private/utils/analytics/mixpanel.dart'; +import 'package:friend_private/utils/other/temp.dart'; +import 'package:friend_private/widgets/extensions/string.dart'; +import 'package:provider/provider.dart'; + +class AppSectionCard extends StatelessWidget { + final String title; + final List apps; + final double? height; + const AppSectionCard({super.key, required this.title, required this.apps, this.height}); + + @override + Widget build(BuildContext context) { + if (apps.isEmpty) { + return const SizedBox.shrink(); + } + return Container( + padding: const EdgeInsets.only(top: 16.0, left: 10, right: 10), + height: height ?? MediaQuery.sizeOf(context).height * 0.4, + margin: const EdgeInsets.only(left: 6.0, right: 6.0, top: 12, bottom: 14), + decoration: BoxDecoration( + color: Colors.grey.shade900, + borderRadius: BorderRadius.circular(16.0), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: Text(title, style: const TextStyle(color: Colors.white, fontSize: 18)), + ), + const SizedBox(height: 16), + Expanded( + child: GridView.builder( + scrollDirection: Axis.horizontal, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + childAspectRatio: 0.3, + crossAxisSpacing: 6, + mainAxisSpacing: 6, + ), + itemCount: apps.length, + itemBuilder: (context, index) => SectionAppItemCard( + app: apps[index], + index: index, + ), + ), + ), + ], + ), + ); + } +} + +class SectionAppItemCard extends StatelessWidget { + final App app; + final int index; + + const SectionAppItemCard({super.key, required this.app, required this.index}); + + @override + Widget build(BuildContext context) { + return Consumer(builder: (context, provider, child) { + return GestureDetector( + onTap: () async { + MixpanelManager().pageOpened('App Detail From Popular Apps Section'); + await routeToPage(context, AppDetailPage(app: app)); + provider.setApps(); + }, + child: Container( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CachedNetworkImage( + imageUrl: app.getImageUrl(), + imageBuilder: (context, imageProvider) => Container( + width: 52, + height: 52, + decoration: BoxDecoration( + shape: BoxShape.rectangle, + borderRadius: BorderRadius.circular(8), + image: DecorationImage(image: imageProvider, fit: BoxFit.cover), + ), + ), + placeholder: (context, url) => const SizedBox( + width: 52, + height: 52, + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ), + errorWidget: (context, url, error) => const Icon(Icons.error), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + app.name, + maxLines: 1, + style: const TextStyle(fontWeight: FontWeight.w600, color: Colors.white, fontSize: 16), + ), + SizedBox(height: app.ratingAvg != null ? 4 : 0), + Padding( + padding: const EdgeInsets.only(top: 4.0), + child: Text( + app.category.decodeString, + maxLines: 2, + style: const TextStyle(color: Colors.grey, fontSize: 14), + ), + ), + app.ratingAvg != null || app.installs > 0 + ? Padding( + padding: const EdgeInsets.only(top: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + app.ratingAvg != null + ? Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text(app.getRatingAvg()!), + const SizedBox(width: 4), + const Icon(Icons.star, color: Colors.deepPurple, size: 16), + const SizedBox(width: 16), + ], + ) + : const SizedBox(), + ], + ), + ) + : Container(), + ], + ), + ), + const SizedBox(width: 16), + ], + ), + ), + ); + }); + } +} diff --git a/app/lib/pages/apps/widgets/filter_sheet.dart b/app/lib/pages/apps/widgets/filter_sheet.dart new file mode 100644 index 000000000..04a78f1eb --- /dev/null +++ b/app/lib/pages/apps/widgets/filter_sheet.dart @@ -0,0 +1,212 @@ +import 'package:flutter/material.dart'; +import 'package:friend_private/providers/app_provider.dart'; +import 'package:provider/provider.dart'; + +class FilterBottomSheet extends StatelessWidget { + const FilterBottomSheet({super.key}); + + @override + Widget build(BuildContext context) { + return DraggableScrollableSheet( + expand: false, + maxChildSize: 0.8, + initialChildSize: 0.6, + minChildSize: 0.4, + builder: (context, scrollController) { + return Consumer(builder: (context, provider, child) { + return Scaffold( + body: SingleChildScrollView( + controller: scrollController, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Filters', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () { + provider.filterApps(); + Navigator.of(context).pop(); + }, + ), + ], + ), + const SizedBox(height: 16), + FilterSection( + title: 'Sort', + child: Column( + children: [ + FilterOption( + label: 'A-Z', + onTap: () { + provider.addOrRemoveFilter('A-Z', 'Sort'); + }, + isSelected: provider.isFilterSelected('A-Z', 'Sort'), + ), + FilterOption( + label: 'Z-A', + onTap: () { + provider.addOrRemoveFilter('Z-A', 'Sort'); + }, + isSelected: provider.isFilterSelected('Z-A', 'Sort'), + ), + FilterOption( + label: 'Highest Rating', + onTap: () { + provider.addOrRemoveFilter('Highest Rating', 'Sort'); + }, + isSelected: provider.isFilterSelected('Highest Rating', 'Sort'), + ), + FilterOption( + label: 'Lowest Rating', + onTap: () { + provider.addOrRemoveFilter('Lowest Rating', 'Sort'); + }, + isSelected: provider.isFilterSelected('Lowest Rating', 'Sort'), + ), + ], + )), + FilterSection( + title: 'Category', + child: Column( + children: provider.categories + .map((category) => FilterOption( + label: category.title, + onTap: () { + provider.addOrRemoveCategoryFilter(category); + }, + isSelected: provider.isCategoryFilterSelected(category), + )) + .toList(), + ), + ), + FilterSection( + title: 'Rating', + child: Column( + children: [ + FilterOption( + label: '1+ Stars', + onTap: () { + provider.addOrRemoveFilter('1+ Stars', 'Rating'); + }, + isSelected: provider.isFilterSelected('1+ Stars', 'Rating')), + FilterOption( + label: '2+ Stars', + onTap: () { + provider.addOrRemoveFilter('2+ Stars', 'Rating'); + }, + isSelected: provider.isFilterSelected('2+ Stars', 'Rating')), + FilterOption( + label: '3+ Stars', + onTap: () { + provider.addOrRemoveFilter('3+ Stars', 'Rating'); + }, + isSelected: provider.isFilterSelected('3+ Stars', 'Rating')), + FilterOption( + label: '4+ Stars', + onTap: () { + provider.addOrRemoveFilter('4+ Stars', 'Rating'); + }, + isSelected: provider.isFilterSelected('4+ Stars', 'Rating')), + ], + ), + ), + FilterSection( + title: 'Capabilities', + child: Column( + children: provider.capabilities + .map((capability) => FilterOption( + label: capability.title, + onTap: () { + provider.addOrRemoveCapabilityFilter(capability); + }, + isSelected: provider.isCapabilityFilterSelected(capability), + )) + .toList(), + ), + ), + const SizedBox(height: 20), + ], + ), + ), + ), + bottomNavigationBar: Padding( + padding: const EdgeInsets.fromLTRB(40, 10, 40, 40), + child: ElevatedButton( + onPressed: () { + provider.clearFilters(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.grey[300], + padding: const EdgeInsets.symmetric(horizontal: 32), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + ), + child: const Text('Clear Filters'), + ), + ), + ); + }); + }, + ); + } +} + +class FilterSection extends StatelessWidget { + final String title; + final Widget? child; + + const FilterSection({super.key, required this.title, this.child}); + + @override + Widget build(BuildContext context) { + return ExpansionTile( + iconColor: Colors.white, + title: Text( + title, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w500, color: Colors.white), + ), + children: [if (child != null) child!], + ); + } +} + +class FilterOption extends StatelessWidget { + final String label; + final Function()? onTap; + final bool isSelected; + + const FilterOption({super.key, required this.label, this.onTap, this.isSelected = false}); + + @override + Widget build(BuildContext context) { + return ListTile( + leading: SizedBox( + height: 22.0, + width: 22.0, + child: Checkbox( + shape: const CircleBorder(), + value: isSelected, + onChanged: (value) { + if (onTap != null) { + onTap!(); + } + }, + ), + ), + title: Text(label), + onTap: onTap, + ); + } +} diff --git a/app/lib/pages/apps/widgets/info_card_widget.dart b/app/lib/pages/apps/widgets/info_card_widget.dart index 00bbc4788..d737c1d92 100644 --- a/app/lib/pages/apps/widgets/info_card_widget.dart +++ b/app/lib/pages/apps/widgets/info_card_widget.dart @@ -22,7 +22,7 @@ class InfoCardWidget extends StatelessWidget { child: Container( width: double.infinity, padding: const EdgeInsets.all(16.0), - margin: const EdgeInsets.only(left: 6.0, right: 6.0, top: 12, bottom: 6), + margin: const EdgeInsets.only(left: 8.0, right: 8.0, top: 12, bottom: 6), decoration: BoxDecoration( color: Colors.grey.shade900, borderRadius: BorderRadius.circular(16.0), diff --git a/app/lib/pages/home/page.dart b/app/lib/pages/home/page.dart index fae6fae29..9ea278087 100644 --- a/app/lib/pages/home/page.dart +++ b/app/lib/pages/home/page.dart @@ -277,7 +277,9 @@ class _HomePageState extends State with WidgetsBindingObserver, Ticker ), Consumer( builder: (context, home, child) { - if (home.chatFieldFocusNode.hasFocus || home.memoryFieldFocusNode.hasFocus) { + if (home.chatFieldFocusNode.hasFocus || + home.memoryFieldFocusNode.hasFocus || + home.appsSearchFieldFocusNode.hasFocus) { return const SizedBox.shrink(); } else { return Align( diff --git a/app/lib/providers/app_provider.dart b/app/lib/providers/app_provider.dart index 5651e2c87..9d8b295f6 100644 --- a/app/lib/providers/app_provider.dart +++ b/app/lib/providers/app_provider.dart @@ -14,6 +14,7 @@ class AppProvider extends BaseProvider { bool filterMemories = true; bool filterExternal = true; String searchQuery = ''; + bool installedAppsOptionSelected = true; List appLoading = []; @@ -24,6 +25,126 @@ class AppProvider extends BaseProvider { bool isLoading = false; + List categories = []; + List capabilities = []; + Map filters = {}; + List filteredApps = []; + + List get userPrivateApps => apps.where((app) => app.private).toList(); + + List get userPublicApps => + apps.where((app) => (!app.private && app.uid == SharedPreferencesUtil().uid)).toList(); + + void updateInstalledAppsOptionSelected(bool value) { + installedAppsOptionSelected = value; + notifyListeners(); + } + + void addOrRemoveFilter(String filter, String filterGroup) { + if (filters.containsKey(filterGroup)) { + filters[filterGroup] = filter; + } else { + filters.addAll({filterGroup: filter}); + } + notifyListeners(); + } + + void addOrRemoveCategoryFilter(Category category) { + if (filters.containsKey('Category')) { + filters['Category'] = category; + } else { + filters.addAll({'Category': category}); + } + notifyListeners(); + } + + void addOrRemoveCapabilityFilter(AppCapability capability) { + if (filters.containsKey('Capabilities')) { + filters['Capabilities'] = capability; + } else { + filters.addAll({'Capabilities': capability}); + } + notifyListeners(); + } + + bool isFilterSelected(String filter, String filterGroup) { + return filters.containsKey(filterGroup) && filters[filterGroup] == filter; + } + + bool isCategoryFilterSelected(Category category) { + return filters.containsKey('Category') && filters['Category'] == category; + } + + bool isCapabilityFilterSelected(AppCapability capability) { + return filters.containsKey('Capabilities') && filters['Capabilities'] == capability; + } + + void clearFilters() { + filters.clear(); + notifyListeners(); + } + + void removeFilter(String filterGroup) { + filters.remove(filterGroup); + notifyListeners(); + } + + bool isFilterActive() { + return filters.isNotEmpty; + } + + bool isSearchActive() { + return searchQuery.isNotEmpty; + } + + void searchApps(String query) { + if (query.isEmpty) { + filteredApps = apps; + searchQuery = ''; + } else { + searchQuery = query; + filteredApps = apps.where((app) => app.name.toLowerCase().contains(query.toLowerCase())).toList(); + } + notifyListeners(); + } + + void filterApps() { + if (!isFilterActive()) { + filteredApps = apps; + return; + } + filteredApps = apps; + filters.forEach((key, value) { + switch (key) { + case 'Sort': + if (value == 'A-Z') { + filteredApps.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); + } else if (value == 'Z-A') { + filteredApps.sort((a, b) => b.name.toLowerCase().compareTo(a.name.toLowerCase())); + } else if (value == 'Highest Rating') { + filteredApps.sort((a, b) => (b.ratingAvg ?? 0.0).compareTo(a.ratingAvg ?? 0.0)); + } else if (value == 'Lowest Rating') { + filteredApps.sort((a, b) => (a.ratingAvg ?? 0.0).compareTo(b.ratingAvg ?? 0.0)); + } + break; + case 'Category': + filteredApps = filteredApps.where((app) => app.category == (value as Category).id).toList(); + break; + case 'Rating': + value = value as String; + value = value.replaceAll('+ Stars', ''); + filteredApps = filteredApps.where((app) => (app.ratingAvg ?? 0.0) >= double.parse(value)).toList(); + break; + case 'Capabilities': + filteredApps = filteredApps.where((app) => app.capabilities.contains((value as AppCapability).id)).toList(); + break; + default: + break; + } + }); + notifyListeners(); + } + void setIsLoading(bool value) { isLoading = value; notifyListeners(); diff --git a/app/lib/providers/home_provider.dart b/app/lib/providers/home_provider.dart index b44809d72..76f802f98 100644 --- a/app/lib/providers/home_provider.dart +++ b/app/lib/providers/home_provider.dart @@ -8,6 +8,8 @@ class HomeProvider extends ChangeNotifier { int selectedIndex = 0; final FocusNode memoryFieldFocusNode = FocusNode(); final FocusNode chatFieldFocusNode = FocusNode(); + final FocusNode appsSearchFieldFocusNode = FocusNode(); + bool isAppsSearchFieldFocused = false; bool isMemoryFieldFocused = false; bool isChatFieldFocused = false; bool hasSpeakerProfile = true; @@ -54,11 +56,13 @@ class HomeProvider extends ChangeNotifier { HomeProvider() { memoryFieldFocusNode.addListener(_onFocusChange); chatFieldFocusNode.addListener(_onFocusChange); + appsSearchFieldFocusNode.addListener(_onFocusChange); } void _onFocusChange() { isMemoryFieldFocused = memoryFieldFocusNode.hasFocus; isChatFieldFocused = chatFieldFocusNode.hasFocus; + isAppsSearchFieldFocused = appsSearchFieldFocusNode.hasFocus; notifyListeners(); } @@ -107,8 +111,10 @@ class HomeProvider extends ChangeNotifier { void dispose() { memoryFieldFocusNode.removeListener(_onFocusChange); chatFieldFocusNode.removeListener(_onFocusChange); + appsSearchFieldFocusNode.removeListener(_onFocusChange); memoryFieldFocusNode.dispose(); chatFieldFocusNode.dispose(); + appsSearchFieldFocusNode.dispose(); super.dispose(); } }