diff --git a/lib/providers/collection_providers.dart b/lib/providers/collection_providers.dart index 35bc4aa0c..e03c36c46 100644 --- a/lib/providers/collection_providers.dart +++ b/lib/providers/collection_providers.dart @@ -32,6 +32,35 @@ final StateNotifierProvider?> hiveHandler, )); +// Manages the set of visible tab IDs +final visibleTabsProvider = StateNotifierProvider>((ref) { + return VisibleTabsNotifier(ref); +}); + +class VisibleTabsNotifier extends StateNotifier> { + VisibleTabsNotifier(this.ref) : super({}); + + final Ref ref; + + // Toggles a tab’s visibility in the tab bar + void toggleVisibility(String id) { + state = Set.from(state); + if (state.contains(id)) { + state.remove(id); // Hide the tab + } else { + state.add(id); // Show the tab (if reopened) + } + } + + void showAll() { + state = ref.read(requestSequenceProvider).toSet(); + } + + void addSingleTab(String id) { + state = {id}; + } +} + class CollectionStateNotifier extends StateNotifier?> { CollectionStateNotifier( @@ -45,8 +74,6 @@ class CollectionStateNotifier state!.keys.first, ]; } - ref.read(selectedIdStateProvider.notifier).state = - ref.read(requestSequenceProvider)[0]; }); } @@ -77,6 +104,7 @@ class CollectionStateNotifier .read(requestSequenceProvider.notifier) .update((state) => [id, ...state]); ref.read(selectedIdStateProvider.notifier).state = newRequestModel.id; + ref.read(visibleTabsProvider.notifier).toggleVisibility(id); unsave(); } @@ -97,6 +125,7 @@ class CollectionStateNotifier .read(requestSequenceProvider.notifier) .update((state) => [id, ...state]); ref.read(selectedIdStateProvider.notifier).state = newRequestModel.id; + ref.read(visibleTabsProvider.notifier).toggleVisibility(id); unsave(); } @@ -110,21 +139,24 @@ class CollectionStateNotifier void remove({String? id}) { final rId = id ?? ref.read(selectedIdStateProvider); + if (rId == null) return; + var itemIds = ref.read(requestSequenceProvider); - int idx = itemIds.indexOf(rId!); + int idx = itemIds.indexOf(rId); cancelHttpRequest(rId); itemIds.remove(rId); ref.read(requestSequenceProvider.notifier).state = [...itemIds]; + ref.read(visibleTabsProvider.notifier).state = + ref.read(visibleTabsProvider).where((tabId) => tabId != rId).toSet(); + String? newId; - if (idx == 0 && itemIds.isNotEmpty) { - newId = itemIds[0]; - } else if (itemIds.length > 1) { - newId = itemIds[idx - 1]; + if (itemIds.isNotEmpty) { + newId = idx < itemIds.length ? itemIds[idx] : itemIds.last; } else { newId = null; } - + ref.read(selectedIdStateProvider.notifier).state = newId; var map = {...state!}; @@ -175,6 +207,7 @@ class CollectionStateNotifier ref.read(requestSequenceProvider.notifier).state = [...itemIds]; ref.read(selectedIdStateProvider.notifier).state = newId; + ref.read(visibleTabsProvider.notifier).toggleVisibility(newId); unsave(); } @@ -201,6 +234,7 @@ class CollectionStateNotifier ref.read(requestSequenceProvider.notifier).state = [...itemIds]; ref.read(selectedIdStateProvider.notifier).state = newId; + ref.read(visibleTabsProvider.notifier).toggleVisibility(newId); unsave(); } diff --git a/lib/screens/home_page/collection_pane.dart b/lib/screens/home_page/collection_pane.dart index 4573b7eaa..cfc7435f0 100644 --- a/lib/screens/home_page/collection_pane.dart +++ b/lib/screens/home_page/collection_pane.dart @@ -194,6 +194,9 @@ class RequestItem extends ConsumerWidget { editRequestId: editRequestId, onTap: () { ref.read(selectedIdStateProvider.notifier).state = id; + if (!ref.read(visibleTabsProvider).contains(id)) { + ref.read(visibleTabsProvider.notifier).toggleVisibility(id); + } kHomeScaffoldKey.currentState?.closeDrawer(); }, onSecondaryTap: () { diff --git a/lib/screens/home_page/editor_pane/editor_default.dart b/lib/screens/home_page/editor_pane/editor_default.dart index 757d1c300..fb73562e0 100644 --- a/lib/screens/home_page/editor_pane/editor_default.dart +++ b/lib/screens/home_page/editor_pane/editor_default.dart @@ -9,33 +9,62 @@ class RequestEditorDefault extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - return Column( - mainAxisAlignment: MainAxisAlignment.center, + return Stack( children: [ - Text.rich( - TextSpan( - children: [ - TextSpan( - text: "Click ", - style: Theme.of(context).textTheme.titleMedium, - ), - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: ElevatedButton( - onPressed: () { - ref.read(collectionStateNotifierProvider.notifier).add(); - }, - child: const Text( - kLabelPlusNew, - style: kTextStyleButton, - ), - ), + Align( + alignment: Alignment.topCenter, + child: Padding( + padding: const EdgeInsets.only(top: 80.0), + child: Opacity( + opacity: 0.1, + child: const FlutterLogo( + size: 400, ), + // TODO: Replace FlutterLogo with apidash_logo + // child: Image.asset( + // 'assets/apidash_logo.png', + // width: X, + // height: X, + // ), + // OR use SVG : + // child: SvgPicture.asset( + // 'assets/apidash_logo.svg', + // width: X, + // height: X, + // ), + ), + ), + ), + Center( + child: Padding( + padding: const EdgeInsets.only(top: 200.0), + child: Text.rich( TextSpan( - text: " to start drafting a new API request.", - style: Theme.of(context).textTheme.titleMedium, + children: [ + TextSpan( + text: "Click ", + style: Theme.of(context).textTheme.titleMedium, + ), + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: ElevatedButton( + onPressed: () { + ref.read(collectionStateNotifierProvider.notifier).add(); + }, + child: const Text( + kLabelPlusNew, + style: kTextStyleButton, + ), + ), + ), + TextSpan( + text: " to start drafting a new API request.", + style: Theme.of(context).textTheme.titleMedium, + ), + ], ), - ], + textAlign: TextAlign.center, + ), ), ), ], diff --git a/lib/screens/home_page/editor_pane/editor_pane.dart b/lib/screens/home_page/editor_pane/editor_pane.dart index 7ff67f5c1..d92b19821 100644 --- a/lib/screens/home_page/editor_pane/editor_pane.dart +++ b/lib/screens/home_page/editor_pane/editor_pane.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:apidash/providers/providers.dart'; import 'editor_default.dart'; import 'editor_request.dart'; +import 'package:apidash/widgets/logo_apidash.dart'; class RequestEditorPane extends ConsumerWidget { const RequestEditorPane({ @@ -12,10 +13,15 @@ class RequestEditorPane extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final selectedId = ref.watch(selectedIdStateProvider); - if (selectedId == null) { + final collection = ref.watch(collectionStateNotifierProvider); + + if (collection == null || collection.isEmpty) { //NO collection (empty or null) -> Show logo + text return const RequestEditorDefault(); - } else { - return const RequestEditor(); } + if (selectedId == null) { // No selectedId -> Show only the logo + return const LogoApidash(); + } + + return const RequestEditor(); } } diff --git a/lib/screens/home_page/editor_pane/editor_request.dart b/lib/screens/home_page/editor_pane/editor_request.dart index 902e5151a..18b6ee4bf 100644 --- a/lib/screens/home_page/editor_pane/editor_request.dart +++ b/lib/screens/home_page/editor_pane/editor_request.dart @@ -5,6 +5,7 @@ import 'details_card/details_card.dart'; import 'details_card/request_pane/request_pane.dart'; import 'request_editor_top_bar.dart'; import 'url_card.dart'; +import 'tab_pane.dart'; class RequestEditor extends StatelessWidget { const RequestEditor({super.key}); @@ -12,29 +13,31 @@ class RequestEditor extends StatelessWidget { @override Widget build(BuildContext context) { return context.isMediumWindow - ? const Padding( + ? Padding( padding: kPb10, child: Column( children: [ kVSpacer20, Expanded( - child: EditRequestPane(), + child: const EditRequestPane(), ), ], ), ) : Padding( padding: kIsMacOS || kIsWindows ? kPt28o8 : kP8, - child: const Column( + child: Column( children: [ - RequestEditorTopBar(), - EditorPaneRequestURLCard(), + const TabPane(), kVSpacer10, - Expanded( + const RequestEditorTopBar(), + const EditorPaneRequestURLCard(), + kVSpacer10, + const Expanded( child: EditorPaneRequestDetailsCard(), ), ], ), ); } -} +} \ No newline at end of file diff --git a/lib/screens/home_page/editor_pane/tab_pane.dart b/lib/screens/home_page/editor_pane/tab_pane.dart new file mode 100644 index 000000000..13cdae7a9 --- /dev/null +++ b/lib/screens/home_page/editor_pane/tab_pane.dart @@ -0,0 +1,69 @@ +import 'package:apidash_design_system/apidash_design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:apidash/consts.dart'; +import 'package:apidash/providers/collection_providers.dart'; +import 'package:apidash/models/models.dart'; +import 'package:apidash/widgets/tab_request_card.dart'; +import 'package:apidash/utils/utils.dart'; + +class TabPane extends ConsumerWidget { + const TabPane({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final collection = ref.watch(collectionStateNotifierProvider); + final requestSequence = ref.watch(requestSequenceProvider); + final selectedId = ref.watch(selectedIdStateProvider); + final visibleTabs = ref.watch(visibleTabsProvider); + + // Prevents rendering if data isn’t ready + if (collection == null || requestSequence.isEmpty) { + return const SizedBox.shrink(); + } + + return Container( + color: Theme.of(context).colorScheme.surfaceContainerLowest, + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), + child: SingleChildScrollView( // horizontal scrolling for the tab bar + scrollDirection: Axis.horizontal, + child: ConstrainedBox( + constraints: BoxConstraints(minWidth: MediaQuery.of(context).size.width), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + // Filters and maps only visible tabs for rendering + children: requestSequence + .where((id) => visibleTabs.contains(id)) + .map((id) { + final request = collection[id]!; + final name = request.name.isNotEmpty + ? request.name + : getRequestTitleFromUrl(request.httpRequestModel?.url) ?? 'Untitled'; + + return TabRequestCard( + apiType: request.apiType, + method: request.httpRequestModel!.method, + name: name, + isSelected: selectedId == id, + onTap: () => ref.read(selectedIdStateProvider.notifier).state = id, + // Manages tab closure and selection adjustment + onClose: () { + ref.read(visibleTabsProvider.notifier).toggleVisibility(id); + if (selectedId == id) { + final remainingTabs = requestSequence.where((tabId) => visibleTabs.contains(tabId) && tabId != id).toList(); + if (remainingTabs.isNotEmpty) { + ref.read(selectedIdStateProvider.notifier).state = remainingTabs.first; + } else { + ref.read(selectedIdStateProvider.notifier).state = null; + } + } + }, + ); + }) + .toList(), + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/logo_apidash.dart b/lib/widgets/logo_apidash.dart new file mode 100644 index 000000000..8428172a2 --- /dev/null +++ b/lib/widgets/logo_apidash.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; + +class LogoApidash extends StatelessWidget { + const LogoApidash({super.key}); + + @override + Widget build(BuildContext context) { + return Center( + child: Opacity( + opacity: 0.1, + child: const FlutterLogo( + size: 400, + ), + // TODO: Replace FlutterLogo with apidash_logo + // child: Image.asset( + // 'assets/apidash_logo.png', + // width: X, + // height: X, + // ), + // OR use SVG: + // child: SvgPicture.asset( + // 'assets/apidash_logo.svg', + // width: X, + // height: X, + // ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/tab_request_card.dart b/lib/widgets/tab_request_card.dart new file mode 100644 index 000000000..36acbeeb8 --- /dev/null +++ b/lib/widgets/tab_request_card.dart @@ -0,0 +1,94 @@ +import 'package:apidash_core/apidash_core.dart'; +import 'package:apidash_design_system/apidash_design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:apidash/consts.dart'; +import 'package:apidash/utils/utils.dart'; +import 'package:apidash/widgets/texts.dart'; + +class TabRequestCard extends StatefulWidget { + const TabRequestCard({ + super.key, + required this.apiType, + required this.method, + required this.name, + required this.isSelected, + this.onTap, + this.onClose, + }); + + final APIType apiType; + final HTTPVerb method; + final String name; + final bool isSelected; + final VoidCallback? onTap; + final VoidCallback? onClose; + + @override + State createState() => _TabRequestCardState(); +} + +class _TabRequestCardState extends State { + bool _isHovering = false; + + @override + Widget build(BuildContext context) { + return Card( + elevation: widget.isSelected ? 1 : 0, + shape: const RoundedRectangleBorder(borderRadius: kBorderRadius8), + margin: const EdgeInsets.only(right: 4), + child: Stack( + children: [ + // MouseRegion to detect hover state + MouseRegion( + onEnter: (_) => setState(() => _isHovering = true), + onExit: (_) => setState(() => _isHovering = false), + child: InkWell( + onTap: widget.onTap, + borderRadius: kBorderRadius8, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SidebarRequestCardTextBox(apiType: widget.apiType, method: widget.method), + kHSpacer8, + Text( + widget.name, + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: TextStyle(color: Theme.of(context).colorScheme.onSurface), + ), + kHSpacer8, + // Show close button only when hovering + if (_isHovering) + GestureDetector( + onTap: widget.onClose, + child: Icon( + Icons.close, + size: 16, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + // Add space even when close button is not visible + if (!_isHovering) + const SizedBox(width: 16), + ], + ), + ), + ), + ), + if (widget.isSelected) // Adds a visual indicator for the selected tab as mentioned in wireframe + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Container( + height: 2, + color: Theme.of(context).colorScheme.primary, + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/widgets.dart b/lib/widgets/widgets.dart index f64276042..0d4667803 100644 --- a/lib/widgets/widgets.dart +++ b/lib/widgets/widgets.dart @@ -61,3 +61,4 @@ export 'texts.dart'; export 'uint8_audio_player.dart'; export 'window_caption.dart'; export 'workspace_selector.dart'; +export 'logo_apidash.dart'; \ No newline at end of file