Files
com.nahnah.florid/lib/screens/updates_screen.dart
T

621 lines
21 KiB
Dart

import 'package:florid/l10n/app_localizations.dart';
import 'package:florid/providers/settings_provider.dart';
import 'package:florid/utils/menu_actions.dart';
import 'package:florid/widgets/changelog_preview.dart';
import 'package:florid/widgets/f_tabbar.dart';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import '../models/fdroid_app.dart';
import '../providers/app_provider.dart';
import '../providers/download_provider.dart';
import '../widgets/app_list_item.dart';
import 'app_details_screen.dart';
class UpdatesScreen extends StatefulWidget {
const UpdatesScreen({super.key});
@override
State<UpdatesScreen> createState() => _UpdatesScreenState();
}
class _UpdatesScreenState extends State<UpdatesScreen>
with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin {
late TabController _tabController;
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
WidgetsBinding.instance.addPostFrameCallback((_) {
_loadData();
});
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
bool get wantKeepAlive => true;
void _loadData() {
final appProvider = context.read<AppProvider>();
// Load both repository data and installed apps
appProvider.fetchRepository();
appProvider.fetchInstalledApps();
}
Future<void> _onRefresh() async {
final appProvider = context.read<AppProvider>();
await Future.wait([appProvider.refreshAll()]);
}
@override
Widget build(BuildContext context) {
super.build(context);
return Consumer<AppProvider>(
builder: (context, appProvider, child) {
final repositoryLoaded = appProvider.repository != null;
final repositoryState = appProvider.repositoryState;
final repositoryError = appProvider.repositoryError;
final installedAppsState = appProvider.installedAppsState;
final installedApps = appProvider.installedApps;
// Do not block UI on repository loading; render with guarded data.
// Show error if failed to load installed apps
if (installedAppsState == LoadingState.error) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Symbols.error,
size: 64,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 16),
Text(
'Failed to check installed apps',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
const Text(
'Unable to access device app list',
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: _loadData,
icon: const Icon(Symbols.refresh),
label: const Text('Retry'),
),
],
),
);
}
return FutureBuilder<List<FDroidApp>>(
future: repositoryLoaded
? appProvider.getUpdatableApps()
: Future.value(<FDroidApp>[]),
builder: (context, snapshot) {
final updatableApps = snapshot.data ?? <FDroidApp>[];
final favoriteApps = repositoryLoaded
? appProvider.getFavoriteApps()
: <FDroidApp>[];
// Get all F-Droid apps installed on device
final allFDroidApps = installedApps
.where(
(installedApp) =>
appProvider.repository?.apps[installedApp.packageName] !=
null,
)
.map(
(installedApp) =>
appProvider.repository!.apps[installedApp.packageName]!,
)
.toList();
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(
context,
).colorScheme.surfaceContainerLow,
surfaceTintColor: Theme.of(
context,
).colorScheme.surfaceContainerLow,
title: const Text('Apps'),
bottom: FTabBar(
controller: _tabController,
showBadge: true,
items: [
FloridTabBarItem(
icon: Symbols.system_update,
label: AppLocalizations.of(context)!.updates,
badgeCount: updatableApps.length,
),
FloridTabBarItem(
icon: Symbols.devices,
label: AppLocalizations.of(context)!.on_device,
badgeCount: allFDroidApps.length,
),
],
onTabChanged: (index) {
_tabController.animateTo(index);
},
),
),
body: RefreshIndicator(
onRefresh: _onRefresh,
child: TabBarView(
controller: _tabController,
children: [
// Tab 1: Updates Only
_buildUpdatesTab(
context,
appProvider,
context.read<SettingsProvider>(),
updatableApps,
repositoryLoaded,
repositoryState,
repositoryError,
),
// Tab 2: All Installed F-Droid Apps
_buildInstalledAppsTab(
context,
appProvider,
context.read<SettingsProvider>(),
allFDroidApps,
updatableApps,
),
],
),
),
);
},
);
},
);
}
Widget _buildUpdatesTab(
BuildContext context,
AppProvider appProvider,
SettingsProvider settingsProvider,
List<FDroidApp> updatableApps,
bool repositoryLoaded,
LoadingState repositoryState,
String? repositoryError,
) {
// Show loading state
if (repositoryState == LoadingState.loading) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
CircularProgressIndicator(year2023: false),
SizedBox(height: 12),
Text('Loading repository…'),
],
),
);
}
// Show error state
if (!repositoryLoaded && repositoryState == LoadingState.error) {
return SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: Container(
height: MediaQuery.of(context).size.height * 0.7,
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
spacing: 16,
children: [
Icon(
Symbols.cloud_off,
size: 64,
color: Theme.of(context).colorScheme.error,
),
Text(
'Unable to load repository',
style: Theme.of(context).textTheme.headlineSmall,
textAlign: TextAlign.center,
),
if (repositoryError != null)
SelectableText(
repositoryError.replaceAll('Exception: ', ''),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.error,
),
),
Text(
'Check your connection or repository settings, then try again.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
FilledButton.icon(
onPressed: _loadData,
icon: const Icon(Symbols.refresh),
label: const Text('Retry'),
),
const SizedBox(width: 12),
OutlinedButton.icon(
onPressed: () => MenuActions.showSettings(context),
icon: const Icon(Symbols.settings),
label: const Text('Settings'),
),
],
),
],
),
),
);
}
if (updatableApps.isEmpty) {
return SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: Container(
height: MediaQuery.of(context).size.height * 0.7,
alignment: Alignment.center,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Symbols.check_circle, size: 64, color: Colors.green[400]),
const SizedBox(height: 16),
Text(
'All apps are up to date!',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
'No updates available',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
);
}
return Scaffold(
floatingActionButton: Column(
mainAxisSize: MainAxisSize.min,
children: [
FloatingActionButton.extended(
onPressed: () => _updateAllApps(context, updatableApps),
icon: const Icon(Symbols.system_update),
label: const Text('Update All'),
).animate().scale(duration: 300.ms, curve: Curves.easeOutBack),
settingsProvider.themeStyle == ThemeStyle.florid
? SizedBox(height: 86)
: SizedBox.shrink(),
],
),
body: Column(
children: [
// Header
Container(
margin: EdgeInsets.only(top: 16),
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Material(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(99),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: Row(
children: [
Icon(Symbols.system_update),
const SizedBox(width: 12),
Expanded(
child: Text(
'${updatableApps.length} updates available',
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w600),
),
),
],
),
),
),
),
// Apps list
Expanded(
child: ListView.builder(
padding: EdgeInsets.fromLTRB(
8,
8,
8,
settingsProvider.themeStyle == ThemeStyle.florid ? 96 : 16,
),
itemCount: updatableApps.length,
itemBuilder: (context, index) {
final app = updatableApps[index];
final installedApp = appProvider.getInstalledApp(
app.packageName,
);
return Card(
elevation: 0,
child: Column(
children: [
AppListItem(
app: app,
showInstallStatus: true,
onUpdate: () => _updateApp(context, app),
onTap: () async {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => AppDetailsScreen(app: app),
),
);
},
),
if (installedApp != null)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
children: [
Text(
'Update from ${installedApp.versionName ?? 'Unknown'}',
style: Theme.of(context).textTheme.labelMedium
?.copyWith(
color: Theme.of(
context,
).colorScheme.primary,
),
),
Icon(
Symbols.arrow_right_alt,
size: 16,
color: Theme.of(context).colorScheme.primary,
),
Text(
app.latestVersion?.versionName ?? 'Unknown',
style: Theme.of(context).textTheme.labelMedium
?.copyWith(
color: Theme.of(
context,
).colorScheme.primary,
),
),
],
),
),
if (app.latestVersion?.whatsNew != null &&
app.latestVersion!.whatsNew!.isNotEmpty)
ChangelogPreview(text: app.latestVersion!.whatsNew),
],
),
).animate().fadeIn(duration: 300.ms, delay: (100 * index).ms);
},
),
),
],
),
);
}
Widget _buildInstalledAppsTab(
BuildContext context,
AppProvider appProvider,
SettingsProvider settingsProvider,
List<FDroidApp> allFDroidApps,
List<FDroidApp> updatableApps,
) {
if (allFDroidApps.isEmpty) {
return SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: Container(
height: MediaQuery.of(context).size.height * 0.7,
alignment: Alignment.center,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Symbols.check_circle, size: 64, color: Colors.green[400]),
const SizedBox(height: 16),
Text(
'No F-Droid apps installed',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
'No F-Droid apps are installed on this device',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
);
}
final bottomPadding = settingsProvider.themeStyle == ThemeStyle.florid
? 96.0
: 16.0;
return ListView.builder(
padding: EdgeInsets.fromLTRB(8, 8, 8, bottomPadding),
itemCount: allFDroidApps.length,
itemBuilder: (context, index) {
final app = allFDroidApps[index];
final installedApp = appProvider.getInstalledApp(app.packageName);
final hasUpdate = updatableApps.any(
(updateApp) => updateApp.packageName == app.packageName,
);
return Card(
elevation: 0,
child: Column(
children: [
AppListItem(
app: app,
showInstallStatus: true,
onUpdate: hasUpdate ? () => _updateApp(context, app) : null,
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => AppDetailsScreen(app: app),
),
);
},
),
if (app.latestVersion?.whatsNew != null &&
app.latestVersion!.whatsNew!.isNotEmpty)
ChangelogPreview(text: app.latestVersion!.whatsNew),
],
),
).animate().fadeIn(duration: 300.ms, delay: (100 * index).ms);
},
);
}
Future<void> _updateApp(BuildContext context, FDroidApp app) async {
final downloadProvider = context.read<DownloadProvider>();
// Check if already downloading
final downloadInfo = downloadProvider.getDownloadInfo(
app.packageName,
app.latestVersion?.versionName ?? '',
);
if (downloadInfo?.status == DownloadStatus.downloading) {
return; // Already downloading, don't start again
}
try {
// Download the app (permission is handled internally by downloadProvider)
await downloadProvider.downloadApk(app);
// The download provider handles installation automatically
// Just clean up the APK file after a delay
if (context.mounted) {
await Future.delayed(const Duration(seconds: 3));
final finalDownloadInfo = downloadProvider.getDownloadInfo(
app.packageName,
app.latestVersion!.versionName,
);
if (finalDownloadInfo?.filePath != null) {
await downloadProvider.deleteDownloadedFile(
finalDownloadInfo!.filePath!,
);
}
}
} catch (e) {
final errorMsg = e.toString();
if (!errorMsg.contains('cancelled') && !errorMsg.contains('Cancelled')) {
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Update failed: $errorMsg')));
}
}
}
}
Future<void> _updateAllApps(
BuildContext context,
List<FDroidApp> apps,
) async {
if (apps.isEmpty) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
icon: const Icon(Symbols.system_update, size: 48),
title: const Text('Update All Apps?'),
content: Text(
'This will download and install ${apps.length} app updates.\n\n'
'The downloads will happen one at a time.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Update All'),
),
],
),
);
if (confirmed != true) return;
final downloadProvider = context.read<DownloadProvider>();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Starting download of ${apps.length} updates...'),
duration: const Duration(seconds: 2),
),
);
}
// Download apps one by one
int successful = 0;
int failed = 0;
for (final app in apps) {
try {
await downloadProvider.downloadApk(app);
successful++;
// Clean up APK after installation
await Future.delayed(const Duration(seconds: 2));
final downloadInfo = downloadProvider.getDownloadInfo(
app.packageName,
app.latestVersion!.versionName,
);
if (downloadInfo?.filePath != null) {
await downloadProvider.deleteDownloadedFile(downloadInfo!.filePath!);
}
} catch (e) {
final errorMsg = e.toString();
if (!errorMsg.contains('cancelled') &&
!errorMsg.contains('Cancelled')) {
failed++;
}
}
}
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Updates complete: $successful successful${failed > 0 ? ', $failed failed' : ''}',
),
duration: const Duration(seconds: 3),
),
);
}
}
}