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

504 lines
17 KiB
Dart

// lib/ui/pinepods/user_stats.dart
import 'package:flutter/material.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/entities/user_stats.dart';
import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart';
import 'package:pinepods_mobile/services/logging/app_logger.dart';
import 'package:pinepods_mobile/ui/widgets/platform_progress_indicator.dart';
import 'package:pinepods_mobile/core/environment.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
class PinepodsUserStats extends StatefulWidget {
const PinepodsUserStats({super.key});
@override
State<PinepodsUserStats> createState() => _PinepodsUserStatsState();
}
class _PinepodsUserStatsState extends State<PinepodsUserStats> {
final PinepodsService _pinepodsService = PinepodsService();
UserStats? _userStats;
String? _pinepodsVersion;
bool _isLoading = true;
String? _errorMessage;
@override
void initState() {
super.initState();
_initializeCredentials();
_loadUserStats();
}
void _initializeCredentials() {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
if (settings.pinepodsServer != null && settings.pinepodsApiKey != null) {
_pinepodsService.setCredentials(
settings.pinepodsServer!,
settings.pinepodsApiKey!,
);
}
}
/// Calculate responsive cross axis count for stats grid
int _getStatsCrossAxisCount(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
if (screenWidth > 1200) return 4; // Very wide screens (large tablets, desktop)
if (screenWidth > 800) return 3; // Wide tablets like iPad
if (screenWidth > 500) return 2; // Standard phones and small tablets
return 1; // Very small phones (< 500px)
}
/// Calculate responsive aspect ratio for stats cards
double _getStatsAspectRatio(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
if (screenWidth <= 500) {
// Single column on small screens - generous height for content + proper padding
return 2.2; // Allows space for icon + title + value + padding, handles text wrapping
}
return 1.0; // Square aspect ratio for multi-column layouts
}
Future<void> _loadUserStats() async {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
setState(() {
_errorMessage = 'Not logged in';
_isLoading = false;
});
return;
}
try {
final futures = await Future.wait([
_pinepodsService.getUserStats(userId),
_pinepodsService.getPinepodsVersion(),
]);
setState(() {
_userStats = futures[0] as UserStats;
_pinepodsVersion = futures[1] as String;
_isLoading = false;
});
} catch (e) {
setState(() {
_errorMessage = 'Failed to load stats: $e';
_isLoading = false;
});
}
}
Future<void> _launchUrl(String url) async {
final logger = AppLogger();
logger.info('UserStats', 'Attempting to launch URL: $url');
try {
final uri = Uri.parse(url);
// Try to launch directly first (works better on Android)
final launched = await launchUrl(
uri,
mode: LaunchMode.externalApplication,
);
if (!launched) {
logger.warning('UserStats', 'Direct URL launch failed, checking if URL can be launched');
// If direct launch fails, check if URL can be launched
final canLaunch = await canLaunchUrl(uri);
if (!canLaunch) {
throw Exception('No app available to handle this URL');
}
} else {
logger.info('UserStats', 'Successfully launched URL: $url');
}
} catch (e) {
logger.error('UserStats', 'Failed to launch URL: $url', e.toString());
// Show error if URL can't be launched
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Could not open link: $url'),
backgroundColor: Colors.red,
),
);
}
}
}
Widget _buildStatCard(String label, String value, {IconData? icon, Color? iconColor}) {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (icon != null) ...[
Icon(
icon,
size: 32,
color: iconColor ?? Theme.of(context).primaryColor,
),
const SizedBox(height: 8),
],
Text(
label,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Colors.grey[600],
),
textAlign: TextAlign.center,
),
const SizedBox(height: 4),
Text(
value,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
],
),
),
);
}
/// Build sync status card that fits in the grid with consistent styling
Widget _buildSyncStatCard() {
if (_userStats == null) return const SizedBox.shrink();
final stats = _userStats!;
final isNotSyncing = stats.podSyncType.toLowerCase() == 'none';
return _buildStatCard(
'Sync Status',
stats.syncStatusDescription,
icon: isNotSyncing ? Icons.sync_disabled : Icons.sync,
iconColor: isNotSyncing ? Colors.grey : null,
);
}
Widget _buildSyncStatusCard() {
if (_userStats == null) return const SizedBox.shrink();
final stats = _userStats!;
final isNotSyncing = stats.podSyncType.toLowerCase() == 'none';
return Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
Icon(
isNotSyncing ? Icons.sync_disabled : Icons.sync,
size: 32,
color: isNotSyncing ? Colors.grey : Theme.of(context).primaryColor,
),
const SizedBox(height: 8),
Text(
'Podcast Sync Status',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Colors.grey[600],
),
textAlign: TextAlign.center,
),
const SizedBox(height: 4),
Text(
stats.syncStatusDescription,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
if (!isNotSyncing && stats.gpodderUrl.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
stats.gpodderUrl,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
textAlign: TextAlign.center,
),
],
],
),
),
);
}
Widget _buildInfoCard() {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
children: [
// PinePods Logo
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
image: const DecorationImage(
image: AssetImage('assets/images/pinepods-logo.png'),
fit: BoxFit.contain,
),
),
),
const SizedBox(height: 16),
Text(
'App Version: v${Environment.projectVersion}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Server Version: ${_pinepodsVersion ?? "Unknown"}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
Text(
'Thanks for using PinePods! This app was born from a love for podcasts, of homelabs, and a desire to have a secure and central location to manage personal data.',
style: TextStyle(
fontSize: 14,
color: Colors.grey[700],
height: 1.4,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
Text(
'Copyright © 2025 Gooseberry Development',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'The PinePods Mobile App is an open-source podcast player adapted from the Anytime Podcast Player (© 2020 Ben Hills). Portions of this application retain the original BSD 3-Clause license.',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
height: 1.3,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
GestureDetector(
onTap: () => _launchUrl('https://github.com/amugofjava/anytime_podcast_player'),
child: Text(
'View original project on GitHub',
style: TextStyle(
fontSize: 12,
decoration: TextDecoration.underline,
color: Theme.of(context).primaryColor,
),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 20),
// Buttons
Column(
children: [
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () => _launchUrl('https://pinepods.online'),
icon: const Icon(Icons.description),
label: const Text('PinePods Documentation'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () => _launchUrl('https://github.com/madeofpendletonwool/pinepods'),
icon: const Icon(Icons.code),
label: const Text('PinePods GitHub Repo'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () => _launchUrl('https://www.buymeacoffee.com/collinscoffee'),
icon: const Icon(Icons.coffee),
label: const Text('Buy me a Coffee'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () {
showLicensePage(context: context);
},
icon: const Icon(Icons.article_outlined),
label: const Text('Open Source Licenses'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
],
),
],
),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('User Statistics'),
centerTitle: true,
),
body: _isLoading
? const Center(child: PlatformProgressIndicator())
: _errorMessage != null
? Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: Colors.red[300],
),
const SizedBox(height: 16),
Text(
_errorMessage!,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
setState(() {
_isLoading = true;
_errorMessage = null;
});
_loadUserStats();
},
child: const Text('Retry'),
),
],
),
),
)
: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
// Statistics Grid
GridView.count(
crossAxisCount: _getStatsCrossAxisCount(context),
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
childAspectRatio: _getStatsAspectRatio(context),
crossAxisSpacing: 12,
mainAxisSpacing: 12,
children: [
_buildStatCard(
'User Created',
_userStats?.formattedUserCreated ?? '',
icon: Icons.calendar_today,
),
_buildStatCard(
'Podcasts Played',
_userStats?.podcastsPlayed.toString() ?? '',
icon: Icons.play_circle,
),
_buildStatCard(
'Time Listened',
_userStats?.formattedTimeListened ?? '',
icon: Icons.access_time,
),
_buildStatCard(
'Podcasts Added',
_userStats?.podcastsAdded.toString() ?? '',
icon: Icons.library_add,
),
_buildStatCard(
'Episodes Saved',
_userStats?.episodesSaved.toString() ?? '',
icon: Icons.bookmark,
),
_buildStatCard(
'Episodes Downloaded',
_userStats?.episodesDownloaded.toString() ?? '',
icon: Icons.download,
),
// Add sync status as a stat card to maintain consistent layout
_buildSyncStatCard(),
],
),
const SizedBox(height: 16),
// Info Card
_buildInfoCard(),
],
),
),
);
}
}