Files
PinePods-nix/PinePods-0.8.2/mobile/lib/ui/pinepods_podcast_app.dart
2026-03-03 10:57:43 -05:00

1330 lines
51 KiB
Dart

// Copyright 2020 Ben Hills and the project contributors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:async';
import 'package:pinepods_mobile/api/podcast/mobile_podcast_api.dart';
import 'package:pinepods_mobile/api/podcast/podcast_api.dart';
import 'package:pinepods_mobile/bloc/podcast/audio_bloc.dart';
import 'package:pinepods_mobile/bloc/podcast/episode_bloc.dart';
import 'package:pinepods_mobile/bloc/podcast/podcast_bloc.dart';
import 'package:pinepods_mobile/bloc/podcast/queue_bloc.dart';
import 'package:pinepods_mobile/bloc/search/search_bloc.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/bloc/ui/pager_bloc.dart';
import 'package:pinepods_mobile/core/environment.dart';
import 'package:pinepods_mobile/entities/app_settings.dart';
import 'package:pinepods_mobile/entities/feed.dart';
import 'package:pinepods_mobile/entities/podcast.dart';
import 'package:pinepods_mobile/l10n/L.dart';
import 'package:pinepods_mobile/navigation/navigation_route_observer.dart';
import 'package:pinepods_mobile/repository/repository.dart';
import 'package:pinepods_mobile/repository/sembast/sembast_repository.dart';
import 'package:pinepods_mobile/services/audio/audio_player_service.dart';
import 'package:pinepods_mobile/services/audio/default_audio_player_service.dart';
import 'package:pinepods_mobile/services/download/download_service.dart';
import 'package:pinepods_mobile/services/download/mobile_download_manager.dart';
import 'package:pinepods_mobile/services/download/mobile_download_service.dart';
import 'package:pinepods_mobile/services/podcast/mobile_podcast_service.dart';
import 'package:pinepods_mobile/services/podcast/podcast_service.dart';
import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart';
import 'package:pinepods_mobile/services/pinepods/pinepods_audio_service.dart';
import 'package:pinepods_mobile/services/pinepods/oidc_service.dart';
import 'package:pinepods_mobile/services/pinepods/login_service.dart';
import 'package:pinepods_mobile/services/auth_notifier.dart';
import 'package:pinepods_mobile/services/settings/mobile_settings_service.dart';
import 'package:pinepods_mobile/ui/library/downloads.dart';
import 'package:pinepods_mobile/ui/library/library.dart';
import 'package:pinepods_mobile/ui/podcast/mini_player.dart';
import 'package:pinepods_mobile/ui/podcast/podcast_details.dart';
import 'package:pinepods_mobile/ui/search/search.dart';
import 'package:pinepods_mobile/ui/pinepods/search.dart';
import 'package:pinepods_mobile/ui/settings/settings.dart';
import 'package:pinepods_mobile/ui/themes.dart';
import 'package:pinepods_mobile/ui/widgets/action_text.dart';
import 'package:pinepods_mobile/ui/widgets/layout_selector.dart';
import 'package:pinepods_mobile/ui/widgets/search_slide_route.dart';
import 'package:pinepods_mobile/ui/pinepods/home.dart';
import 'package:pinepods_mobile/ui/pinepods/feed.dart';
import 'package:pinepods_mobile/ui/pinepods/saved.dart';
import 'package:pinepods_mobile/ui/pinepods/queue.dart';
import 'package:pinepods_mobile/ui/pinepods/history.dart';
import 'package:pinepods_mobile/ui/pinepods/playlists.dart';
import 'package:pinepods_mobile/ui/auth/auth_wrapper.dart';
import 'package:pinepods_mobile/ui/pinepods/user_stats.dart';
import 'package:pinepods_mobile/ui/pinepods/podcasts.dart';
import 'package:pinepods_mobile/ui/pinepods/episode_search.dart';
import 'package:pinepods_mobile/ui/pinepods/podcast_details.dart';
import 'package:pinepods_mobile/entities/pinepods_search.dart';
import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart';
import 'package:pinepods_mobile/services/podcast/mobile_podcast_service.dart';
import 'package:pinepods_mobile/api/podcast/mobile_podcast_api.dart';
import 'package:app_links/app_links.dart';
import 'package:crypto/crypto.dart';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_dialogs/flutter_dialogs.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:logging/logging.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:pinepods_mobile/services/global_services.dart';
var theme = Themes.lightTheme().themeData;
/// PinePods is a Podcast player. You can search and subscribe to podcasts,
/// download and stream episodes and view the latest podcast charts.
// ignore: must_be_immutable
class PinepodsPodcastApp extends StatefulWidget {
final Repository repository;
late PodcastApi podcastApi;
late DownloadService downloadService;
late AudioPlayerService audioPlayerService;
PodcastService? podcastService;
SettingsBloc? settingsBloc;
MobileSettingsService mobileSettingsService;
List<int> certificateAuthorityBytes;
late PinepodsAudioService pinepodsAudioService;
late PinepodsService pinepodsService;
PinepodsPodcastApp({
super.key,
required this.mobileSettingsService,
required this.certificateAuthorityBytes,
}) : repository = SembastRepository() {
podcastApi = MobilePodcastApi();
podcastService = MobilePodcastService(
api: podcastApi,
repository: repository,
settingsService: mobileSettingsService,
);
assert(podcastService != null);
downloadService = MobileDownloadService(
repository: repository,
downloadManager: MobileDownloaderManager(),
podcastService: podcastService!,
);
audioPlayerService = DefaultAudioPlayerService(
repository: repository,
settingsService: mobileSettingsService,
podcastService: podcastService!,
);
settingsBloc = SettingsBloc(mobileSettingsService);
// Create and connect PinepodsAudioService for listen duration tracking
pinepodsService = PinepodsService();
pinepodsAudioService = PinepodsAudioService(
audioPlayerService!,
pinepodsService,
settingsBloc!,
);
// Connect the services for listen duration recording
(audioPlayerService as DefaultAudioPlayerService).setPinepodsAudioService(
pinepodsAudioService,
);
// Initialize global services for app-wide access
GlobalServices.initialize(
pinepodsAudioService: pinepodsAudioService,
pinepodsService: pinepodsService,
);
podcastApi.addClientAuthorityBytes(certificateAuthorityBytes);
}
@override
PinepodsPodcastAppState createState() => PinepodsPodcastAppState();
}
class PinepodsPodcastAppState extends State<PinepodsPodcastApp> {
ThemeData? theme;
@override
void initState() {
super.initState();
/// Listen to theme change events from settings.
widget.settingsBloc!.settings.listen((event) {
setState(() {
var newTheme = ThemeRegistry.getThemeData(event.theme);
/// Only update the theme if it has changed.
if (newTheme != theme) {
theme = newTheme;
}
});
});
// Initialize theme from current settings
theme = ThemeRegistry.getThemeData(widget.mobileSettingsService.theme);
}
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
Provider<SearchBloc>(
create: (_) => SearchBloc(podcastService: widget.podcastService!),
dispose: (_, value) => value.dispose(),
),
Provider<EpisodeBloc>(
create: (_) => EpisodeBloc(
podcastService: widget.podcastService!,
audioPlayerService: widget.audioPlayerService,
),
dispose: (_, value) => value.dispose(),
),
Provider<PodcastBloc>(
create: (_) => PodcastBloc(
podcastService: widget.podcastService!,
audioPlayerService: widget.audioPlayerService,
downloadService: widget.downloadService,
settingsService: widget.mobileSettingsService,
),
dispose: (_, value) => value.dispose(),
),
Provider<PagerBloc>(
create: (_) => PagerBloc(),
dispose: (_, value) => value.dispose(),
),
Provider<AudioBloc>(
create: (_) =>
AudioBloc(audioPlayerService: widget.audioPlayerService),
dispose: (_, value) => value.dispose(),
),
Provider<SettingsBloc?>(
create: (_) => widget.settingsBloc,
dispose: (_, value) => value!.dispose(),
),
Provider<QueueBloc>(
create: (_) => QueueBloc(
audioPlayerService: widget.audioPlayerService,
podcastService: widget.podcastService!,
),
dispose: (_, value) => value.dispose(),
),
Provider<AudioPlayerService>(create: (_) => widget.audioPlayerService),
Provider<PodcastService>(create: (_) => widget.podcastService!),
],
child: MaterialApp(
debugShowCheckedModeBanner: false,
showSemanticsDebugger: false,
title: 'Pinepods Podcast Client',
navigatorObservers: [NavigationRouteObserver()],
localizationsDelegates: const <LocalizationsDelegate<Object>>[
PinepodsLocalisationsDelegate(),
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [
Locale('en', ''),
Locale('de', ''),
Locale('it', ''),
],
theme: theme,
// Uncomment builder below to enable accessibility checker tool.
// builder: (context, child) => AccessibilityTools(child: child),
home: const AuthWrapper(
child: PinepodsHomePage(title: 'PinePods Podcast Player'),
),
),
);
}
}
class PinepodsHomePage extends StatefulWidget {
final String? title;
final bool topBarVisible;
const PinepodsHomePage({super.key, this.title, this.topBarVisible = true});
@override
State<PinepodsHomePage> createState() => _PinepodsHomePageState();
}
class _PinepodsHomePageState extends State<PinepodsHomePage>
with WidgetsBindingObserver {
StreamSubscription<Uri>? deepLinkSubscription;
final log = Logger('_PinepodsHomePageState');
bool handledInitialLink = false;
Widget? library;
@override
void initState() {
super.initState();
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
WidgetsBinding.instance.addObserver(this);
audioBloc.transitionLifecycleState(LifecycleState.resume);
/// Handle deep links
_setupLinkListener();
}
/// We listen to external links from outside the app. For example, someone may navigate
/// to a web page that supports 'Open with Pinepods'.
void _setupLinkListener() async {
print('Deep Link: Setting up link listener...');
final appLinks = AppLinks(); // AppLinks is singleton
// Handle initial link if app was launched by one (cold start)
try {
final initialUri = await appLinks.getInitialLink();
if (initialUri != null) {
print('Deep Link: App launched with initial link: $initialUri');
_handleLinkEvent(initialUri);
} else {
print('Deep Link: No initial link found');
}
} catch (e) {
print('Deep Link: Error getting initial link: $e');
}
// Subscribe to all events (further links while app is running)
print('Deep Link: Setting up stream listener...');
deepLinkSubscription = appLinks.uriLinkStream.listen((uri) {
print('Deep Link: App received link while running: $uri');
_handleLinkEvent(uri);
}, onError: (err) {
print('Deep Link: Stream error: $err');
});
print('Deep Link: Link listener setup complete');
}
/// This method handles the actual link supplied from [uni_links], either
/// at app startup or during running.
void _handleLinkEvent(Uri uri) async {
print('Deep Link: Received link: $uri');
print('Deep Link: Scheme: ${uri.scheme}, Host: ${uri.host}, Path: ${uri.path}');
print('Deep Link: Query: ${uri.query}');
print('Deep Link: QueryParameters: ${uri.queryParameters}');
// Handle OIDC authentication callback - be more flexible with path matching
if (uri.scheme == 'pinepods' && uri.host == 'auth') {
print('Deep Link: OIDC callback detected (flexible match)');
await _handleOidcCallback(uri);
return;
}
// Handle OIDC authentication callback - strict match
if (uri.scheme == 'pinepods' && uri.host == 'auth' && uri.path == '/callback') {
print('Deep Link: OIDC callback detected (strict match)');
await _handleOidcCallback(uri);
return;
}
// Handle podcast subscription links
if ((uri.scheme == 'pinepods-subscribe' || uri.scheme == 'https') &&
(uri.query.startsWith('uri=') || uri.query.startsWith('url='))) {
var path = uri.query.substring(4);
var loadPodcastBloc = Provider.of<PodcastBloc>(context, listen: false);
var routeName = NavigationRouteObserver().top!.settings.name;
/// If we are currently on the podcast details page, we can simply request (via
/// the BLoC) that we load this new URL. If not, we pop the stack until we are
/// back at root and then load the podcast details page.
if (routeName != null && routeName == 'podcastdetails') {
loadPodcastBloc.load(
Feed(
podcast: Podcast.fromUrl(url: path),
backgroundFresh: false,
silently: false,
),
);
} else {
/// Pop back to route.
Navigator.of(context).popUntil((route) {
var currentRouteName = NavigationRouteObserver().top!.settings.name;
return currentRouteName == null ||
currentRouteName == '' ||
currentRouteName == '/';
});
/// Once we have reached the root route, push podcast details.
await Navigator.push(
context,
MaterialPageRoute<void>(
fullscreenDialog: true,
settings: const RouteSettings(name: 'podcastdetails'),
builder: (context) =>
PodcastDetails(Podcast.fromUrl(url: path), loadPodcastBloc),
),
);
}
}
}
/// Handle OIDC authentication callback
Future<void> _handleOidcCallback(Uri uri) async {
try {
print('OIDC Callback: Received callback URL: $uri');
// Parse the callback result
final callbackResult = OidcService.parseCallback(uri.toString());
if (!callbackResult.isSuccess) {
print('OIDC Callback: Authentication failed: ${callbackResult.error}');
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('OIDC authentication failed: ${callbackResult.error}'),
backgroundColor: Colors.red,
),
);
}
return;
}
// Check if we have an API key directly from the callback
if (callbackResult.hasApiKey) {
print('OIDC Callback: Found API key in callback, completing login');
await _completeOidcLogin(callbackResult.apiKey!);
} else {
print('OIDC Callback: No API key found, traditional OAuth flow not implemented yet');
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('OIDC callback received but no API key found'),
backgroundColor: Colors.orange,
),
);
}
}
} catch (e) {
print('OIDC Callback: Error processing callback: $e');
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error processing OIDC callback: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
/// Complete OIDC login with the provided API key
Future<void> _completeOidcLogin(String apiKey) async {
try {
print('OIDC Callback: Completing login with API key');
// We need to get the server URL - we can get it from the current settings
// since the user would have entered it during the initial OIDC flow
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
// Check if we have a server URL from a previous attempt
String? serverUrl = settings.pinepodsServer;
if (serverUrl == null || serverUrl.isEmpty) {
throw Exception('No server URL available for OIDC completion');
}
// Verify the API key works and get user details
// Verify API key
final isValidKey = await PinepodsLoginService.verifyApiKey(serverUrl, apiKey);
if (!isValidKey) {
throw Exception('API key verification failed');
}
// Get user ID
final userId = await PinepodsLoginService.getUserId(serverUrl, apiKey);
if (userId == null) {
throw Exception('Failed to get user ID');
}
// Get user details
final userDetails = await PinepodsLoginService.getUserDetails(serverUrl, apiKey, userId);
if (userDetails == null) {
throw Exception('Failed to get user details');
}
// Save the authentication details
settingsBloc.setPinepodsServer(serverUrl);
settingsBloc.setPinepodsApiKey(apiKey);
settingsBloc.setPinepodsUserId(userId);
// Set additional user details if available
if (userDetails.username != null) {
settingsBloc.setPinepodsUsername(userDetails.username!);
}
if (userDetails.email != null) {
settingsBloc.setPinepodsEmail(userDetails.email!);
}
// Fetch theme from server
await settingsBloc.fetchThemeFromServer();
print('OIDC Callback: Login completed successfully');
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('OIDC authentication successful!'),
backgroundColor: Colors.green,
),
);
// Log current settings state for debugging
final currentSettings = settingsBloc.currentSettings;
print('OIDC Callback: Current settings after update:');
print(' Server: ${currentSettings.pinepodsServer}');
print(' API Key: ${currentSettings.pinepodsApiKey != null ? '[SET]' : '[NOT SET]'}');
print(' User ID: ${currentSettings.pinepodsUserId}');
print(' Username: ${currentSettings.pinepodsUsername}');
// Notify login success globally
AuthNotifier.notifyLoginSuccess();
}
} catch (e) {
print('OIDC Callback: Error completing login: $e');
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to complete OIDC login: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
@override
void dispose() {
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
audioBloc.transitionLifecycleState(LifecycleState.pause);
deepLinkSubscription?.cancel();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) async {
print('Deep Link: App lifecycle state changed to: $state');
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
switch (state) {
case AppLifecycleState.resumed:
print('Deep Link: App resumed - checking for pending deep links...');
audioBloc.transitionLifecycleState(LifecycleState.resume);
// Check for any pending deep links when app resumes
try {
final appLinks = AppLinks();
final initialUri = await appLinks.getInitialLink();
if (initialUri != null) {
print('Deep Link: Found pending link on resume: $initialUri');
_handleLinkEvent(initialUri);
}
} catch (e) {
print('Deep Link: Error checking for pending links on resume: $e');
}
break;
case AppLifecycleState.paused:
print('Deep Link: App paused');
audioBloc.transitionLifecycleState(LifecycleState.pause);
break;
default:
break;
}
}
@override
Widget build(BuildContext context) {
final pager = Provider.of<PagerBloc>(context);
final searchBloc = Provider.of<EpisodeBloc>(context);
final backgroundColour = Theme.of(context).scaffoldBackgroundColor;
return AnnotatedRegion<SystemUiOverlayStyle>(
value: Theme.of(context).appBarTheme.systemOverlayStyle!,
child: Scaffold(
backgroundColor: backgroundColour,
body: Column(
children: <Widget>[
Expanded(
child: CustomScrollView(
slivers: <Widget>[
SliverVisibility(
visible: widget.topBarVisible,
sliver: SliverAppBar(
title: ExcludeSemantics(child: TitleWidget()),
backgroundColor: backgroundColour,
floating: false,
pinned: true,
snap: false,
actions: <Widget>[
IconButton(
tooltip: 'Queue',
icon: const Icon(Icons.queue_music),
onPressed: () async {
await Navigator.push(
context,
MaterialPageRoute<void>(
fullscreenDialog: false,
settings: const RouteSettings(name: 'queue'),
builder: (context) => Scaffold(
appBar: AppBar(title: const Text('Queue')),
body: const Column(
children: [
Expanded(
child: CustomScrollView(
slivers: [PinepodsQueue()],
),
),
MiniPlayer(),
],
),
),
),
);
},
),
IconButton(
tooltip: L.of(context)!.search_for_podcasts_hint,
icon: const Icon(Icons.search),
onPressed: () async {
await Navigator.push(
context,
defaultTargetPlatform == TargetPlatform.iOS
? MaterialPageRoute<void>(
fullscreenDialog: false,
settings: const RouteSettings(
name: 'pinepods_search',
),
builder: (context) =>
const PinepodsSearch(),
)
: SlideRightRoute(
widget: const PinepodsSearch(),
settings: const RouteSettings(
name: 'pinepods_search',
),
),
);
},
),
PopupMenuButton<String>(
onSelected: _menuSelect,
icon: const Icon(Icons.more_vert),
itemBuilder: (BuildContext context) {
return <PopupMenuEntry<String>>[
if (feedbackUrl.isNotEmpty)
PopupMenuItem<String>(
textStyle: Theme.of(
context,
).textTheme.titleMedium,
value: 'feedback',
child: Row(
crossAxisAlignment:
CrossAxisAlignment.center,
children: [
const Padding(
padding: EdgeInsets.only(right: 8.0),
child: Icon(
Icons.feedback_outlined,
size: 18.0,
),
),
Text(
L.of(context)!.feedback_menu_item_label,
),
],
),
),
PopupMenuItem<String>(
textStyle: Theme.of(
context,
).textTheme.titleMedium,
value: 'rss',
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Padding(
padding: EdgeInsets.only(right: 8.0),
child: Icon(Icons.rss_feed, size: 18.0),
),
Text(L.of(context)!.add_rss_feed_option),
],
),
),
PopupMenuItem<String>(
textStyle: Theme.of(
context,
).textTheme.titleMedium,
value: 'settings',
child: Row(
children: [
const Padding(
padding: EdgeInsets.only(right: 8.0),
child: Icon(Icons.settings, size: 18.0),
),
Text(L.of(context)!.settings_label),
],
),
),
];
},
),
],
),
),
StreamBuilder<int>(
stream: pager.currentPage,
builder:
(BuildContext context, AsyncSnapshot<int> snapshot) {
return _fragment(snapshot.data, searchBloc);
},
),
],
),
),
const MiniPlayer(),
],
),
bottomNavigationBar: StreamBuilder<int>(
stream: pager.currentPage,
initialData: 0,
builder: (BuildContext context, AsyncSnapshot<int> snapshot) {
int index = snapshot.data ?? 0;
return StreamBuilder<AppSettings>(
stream: Provider.of<SettingsBloc>(context).settings,
builder:
(
BuildContext context,
AsyncSnapshot<AppSettings> settingsSnapshot,
) {
final bottomBarOrder =
settingsSnapshot.data?.bottomBarOrder ??
[
'Home',
'Feed',
'Saved',
'Podcasts',
'Downloads',
'History',
'Playlists',
'Search',
];
// Create a map of all available nav items
final Map<String, BottomNavItem> allNavItems = {
'Home': BottomNavItem(
icon: Icons.home,
label: 'Home',
isSelected: false,
),
'Feed': BottomNavItem(
icon: Icons.rss_feed,
label: 'Feed',
isSelected: false,
),
'Saved': BottomNavItem(
icon: Icons.bookmark,
label: 'Saved',
isSelected: false,
),
'Podcasts': BottomNavItem(
icon: Icons.podcasts,
label: 'Podcasts',
isSelected: false,
),
'Downloads': BottomNavItem(
icon: Icons.download,
label: 'Downloads',
isSelected: false,
),
'History': BottomNavItem(
icon: Icons.history,
label: 'History',
isSelected: false,
),
'Playlists': BottomNavItem(
icon: Icons.playlist_play,
label: 'Playlists',
isSelected: false,
),
'Search': BottomNavItem(
icon: Icons.search,
label: 'Search',
isSelected: false,
),
};
// Create the ordered nav items based on settings
final List<BottomNavItem> navItems = bottomBarOrder.map((
label,
) {
final baseItem = allNavItems[label]!;
final itemIndex = bottomBarOrder.indexOf(label);
return BottomNavItem(
icon: index == itemIndex
? _getSelectedIcon(label)
: _getUnselectedIcon(label),
label: label,
isSelected: index == itemIndex,
);
}).toList();
// Calculate if all icons fit in the current screen width
final screenWidth = MediaQuery.of(context).size.width;
final iconWidth = 80.0;
final totalIconsWidth = navItems.length * iconWidth;
final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape;
final shouldCenterInPortrait = !isLandscape && totalIconsWidth <= screenWidth;
return Container(
height: 70 + MediaQuery.of(context).padding.bottom,
decoration: BoxDecoration(
color: Theme.of(context).bottomAppBarTheme.color,
border: Border(
top: BorderSide(
color: Theme.of(context).dividerColor,
width: 0.5,
),
),
),
child: (isLandscape || shouldCenterInPortrait)
? Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom,
),
child: Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: navItems.asMap().entries.map((entry) {
int itemIndex = entry.key;
BottomNavItem item = entry.value;
return GestureDetector(
onTap: () => pager.changePage(itemIndex),
child: Container(
width: 80,
padding: const EdgeInsets.symmetric(
vertical: 8,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
item.icon,
color: item.isSelected
? Theme.of(
context,
).iconTheme.color
: HSLColor.fromColor(
Theme.of(context)
.bottomAppBarTheme
.color!,
)
.withLightness(0.8)
.toColor(),
size: 24,
),
const SizedBox(height: 4),
Text(
item.label,
style: TextStyle(
fontSize: 11,
color: item.isSelected
? Theme.of(
context,
).iconTheme.color
: HSLColor.fromColor(
Theme.of(context)
.bottomAppBarTheme
.color!,
)
.withLightness(0.8)
.toColor(),
fontWeight: item.isSelected
? FontWeight.w600
: FontWeight.normal,
),
textAlign: TextAlign.center,
),
],
),
),
);
}).toList(),
),
),
)
: Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom,
),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: navItems.asMap().entries.map((entry) {
int itemIndex = entry.key;
BottomNavItem item = entry.value;
return GestureDetector(
onTap: () => pager.changePage(itemIndex),
child: Container(
width: 80,
padding: const EdgeInsets.symmetric(
vertical: 8,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
item.icon,
color: item.isSelected
? Theme.of(
context,
).iconTheme.color
: HSLColor.fromColor(
Theme.of(context)
.bottomAppBarTheme
.color!,
)
.withLightness(0.8)
.toColor(),
size: 24,
),
const SizedBox(height: 4),
Text(
item.label,
style: TextStyle(
fontSize: 11,
color: item.isSelected
? Theme.of(
context,
).iconTheme.color
: HSLColor.fromColor(
Theme.of(context)
.bottomAppBarTheme
.color!,
)
.withLightness(0.8)
.toColor(),
fontWeight: item.isSelected
? FontWeight.w600
: FontWeight.normal,
),
textAlign: TextAlign.center,
),
],
),
),
);
}).toList(),
),
),
),
);
},
);
},
),
),
);
}
Widget _fragment(int? index, EpisodeBloc searchBloc) {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final bottomBarOrder = settingsBloc.currentSettings.bottomBarOrder;
if (index == null || index < 0 || index >= bottomBarOrder.length) {
return const PinepodsHome(); // Default to Home
}
final pageLabel = bottomBarOrder[index];
switch (pageLabel) {
case 'Home':
return const PinepodsHome();
case 'Feed':
return const PinepodsFeed();
case 'Saved':
return const PinepodsSaved();
case 'Podcasts':
return const PinepodsPodcasts();
case 'Downloads':
return const Downloads();
case 'History':
return const PinepodsHistory();
case 'Playlists':
return const PinepodsPlaylists();
case 'Search':
return const EpisodeSearchPage();
default:
return const PinepodsHome(); // Default to Home
}
}
IconData _getSelectedIcon(String label) {
switch (label) {
case 'Home':
return Icons.home;
case 'Feed':
return Icons.rss_feed;
case 'Saved':
return Icons.bookmark;
case 'Podcasts':
return Icons.podcasts;
case 'Downloads':
return Icons.download;
case 'History':
return Icons.history;
case 'Playlists':
return Icons.playlist_play;
case 'Search':
return Icons.search;
default:
return Icons.home;
}
}
IconData _getUnselectedIcon(String label) {
switch (label) {
case 'Home':
return Icons.home_outlined;
case 'Feed':
return Icons.rss_feed_outlined;
case 'Saved':
return Icons.bookmark_outline;
case 'Podcasts':
return Icons.podcasts_outlined;
case 'Downloads':
return Icons.download_outlined;
case 'History':
return Icons.history_outlined;
case 'Playlists':
return Icons.playlist_play_outlined;
case 'Search':
return Icons.search_outlined;
default:
return Icons.home_outlined;
}
}
void _menuSelect(String choice) async {
var textFieldController = TextEditingController();
var podcastBloc = Provider.of<PodcastBloc>(context, listen: false);
final theme = Theme.of(context);
var url = '';
switch (choice) {
case 'settings':
await Navigator.push(
context,
MaterialPageRoute<void>(
fullscreenDialog: true,
settings: const RouteSettings(name: 'settings'),
builder: (context) => const Settings(),
),
);
break;
case 'feedback':
_launchFeedback();
break;
case 'rss':
await showPlatformDialog<void>(
context: context,
useRootNavigator: false,
builder: (_) => BasicDialogAlert(
title: Text(L.of(context)!.add_rss_feed_option),
content: Material(
color: Colors.transparent,
child: TextField(
onChanged: (value) {
setState(() {
url = value;
});
},
controller: textFieldController,
decoration: const InputDecoration(hintText: 'https://'),
),
),
actions: <Widget>[
BasicDialogAction(
title: ActionText(L.of(context)!.cancel_button_label),
onPressed: () {
Navigator.pop(context);
},
),
BasicDialogAction(
title: ActionText(L.of(context)!.ok_button_label),
iosIsDefaultAction: true,
onPressed: () async {
Navigator.of(context).pop(); // Close the dialog first
// Show loading indicator
showDialog(
context: context,
barrierDismissible: false,
builder: (context) =>
const Center(child: CircularProgressIndicator()),
);
try {
await _handleRssUrl(url);
} catch (e) {
if (mounted) {
Navigator.of(context).pop(); // Close loading dialog
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to load podcast: $e'),
backgroundColor: Colors.red,
),
);
}
}
},
),
],
),
);
break;
}
}
Future<void> _handleRssUrl(String url) async {
try {
// Get services
final podcastApi = MobilePodcastApi();
final pinepodsService = PinepodsService();
// Load podcast feed from RSS
final podcast = await podcastApi.loadFeed(url);
// Create UnifiedPinepodsPodcast from the loaded feed
final unifiedPodcast = UnifiedPinepodsPodcast(
id: 0,
indexId: 0,
title: podcast.title ?? 'Unknown Podcast',
url: url,
originalUrl: url,
link: podcast.link ?? url,
description: podcast.description ?? '',
author: podcast.copyright ?? '',
ownerName: podcast.copyright ?? '',
image: podcast.image ?? '',
artwork: podcast.image ?? '',
lastUpdateTime: 0,
categories: null,
explicit: false,
episodeCount: podcast.episodes?.length ?? 0,
);
// Check if podcast is already followed
bool isFollowing = false;
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId != null) {
try {
isFollowing = await pinepodsService.checkPodcastExists(
podcast.title ?? 'Unknown Podcast',
url,
userId,
);
} catch (e) {
print('Failed to check if podcast exists: $e');
}
}
if (mounted) {
Navigator.of(context).pop(); // Close loading dialog
// Navigate to podcast details page
Navigator.push(
context,
MaterialPageRoute<void>(
settings: const RouteSettings(name: 'pinepodspodcastdetails'),
builder: (context) => PinepodsPodcastDetails(
podcast: unifiedPodcast,
isFollowing: isFollowing,
onFollowChanged: (following) {
// Handle follow state change if needed
},
),
),
);
}
} catch (e) {
rethrow;
}
}
void _launchFeedback() async {
final uri = Uri.parse(feedbackUrl);
if (!await launchUrl(uri, mode: LaunchMode.externalApplication)) {
throw Exception('Could not launch $uri');
}
}
void _launchEmail() async {
final uri = Uri.parse('mailto:mobile-support@pinepods.online');
if (await canLaunchUrl(uri)) {
await launchUrl(uri);
} else {
throw 'Could not launch $uri';
}
}
}
class TitleWidget extends StatelessWidget {
TitleWidget({super.key});
String _generateGravatarUrl(String email, {int size = 40}) {
final hash = md5
.convert(utf8.encode(email.toLowerCase().trim()))
.toString();
return 'https://www.gravatar.com/avatar/$hash?s=$size&d=identicon';
}
@override
Widget build(BuildContext context) {
return Consumer<SettingsBloc>(
builder: (context, settingsBloc, child) {
final settings = settingsBloc.currentSettings;
final username = settings.pinepodsUsername;
final email = settings.pinepodsEmail;
if (username == null || username.isEmpty) {
// Fallback to PinePods logo if no user is logged in - make it clickable
return GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const PinepodsUserStats(),
),
);
},
child: Padding(
padding: const EdgeInsets.only(left: 2.0),
child: Row(
children: <Widget>[
Text(
'Pine',
style: TextStyle(
color: const Color(0xFF539e8a),
fontWeight: FontWeight.bold,
fontFamily: 'MontserratRegular',
fontSize: 18,
),
),
Text(
'Pods',
style: TextStyle(
color: Theme.of(context).brightness == Brightness.light
? Colors.black
: Colors.white,
fontWeight: FontWeight.bold,
fontFamily: 'MontserratRegular',
fontSize: 18,
),
),
],
),
),
);
}
return GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const PinepodsUserStats(),
),
);
},
child: Padding(
padding: const EdgeInsets.only(left: 2.0),
child: Row(
children: [
// User Avatar
CircleAvatar(
radius: 18,
backgroundColor: Colors.grey[300],
child: email != null && email.isNotEmpty
? ClipOval(
child: Image.network(
_generateGravatarUrl(email),
width: 36,
height: 36,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Image.asset(
'assets/images/pinepods-logo.png',
width: 36,
height: 36,
fit: BoxFit.cover,
);
},
),
)
: Image.asset(
'assets/images/pinepods-logo.png',
width: 36,
height: 36,
fit: BoxFit.cover,
),
),
const SizedBox(width: 12),
// Username
Flexible(
child: Text(
username,
style: TextStyle(
color: Theme.of(context).brightness == Brightness.light
? Colors.black
: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 16,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
);
},
);
}
}
class BottomNavItem {
final IconData icon;
final String label;
final bool isSelected;
BottomNavItem({
required this.icon,
required this.label,
required this.isSelected,
});
}