[feat]: User page

This commit is contained in:
nahnah
2026-02-14 15:41:48 +00:00
parent 82335efea8
commit a42e8db95b
6 changed files with 693 additions and 379 deletions
+11
View File
@@ -21,6 +21,7 @@ class SettingsProvider extends ChangeNotifier {
static const updateIntervalHoursKey = 'background_update_interval_hours';
static const updateNetworkPolicyKey = 'background_update_network_policy';
static const _lastSeenVersionKey = 'last_seen_version';
static const _userNameKey = 'user_name';
ThemeMode _themeMode = ThemeMode.system;
ThemeStyle _themeStyle = ThemeStyle.florid;
@@ -36,6 +37,7 @@ class SettingsProvider extends ChangeNotifier {
UpdateNetworkPolicy _updateNetworkPolicy = UpdateNetworkPolicy.any;
bool _loaded = false;
String _lastSeenVersion = '';
String _userName = '';
SettingsProvider() {
_load();
@@ -55,6 +57,7 @@ class SettingsProvider extends ChangeNotifier {
int get updateIntervalHours => _updateIntervalHours;
UpdateNetworkPolicy get updateNetworkPolicy => _updateNetworkPolicy;
String get lastSeenVersion => _lastSeenVersion;
String get userName => _userName;
/// Available locales for F-Droid repository data
static const List<String> availableLocales = [
@@ -134,6 +137,7 @@ class SettingsProvider extends ChangeNotifier {
_updateNetworkPolicy = UpdateNetworkPolicy.values[policyIndex];
}
_lastSeenVersion = prefs.getString(_lastSeenVersionKey) ?? '';
_userName = prefs.getString(_userNameKey) ?? '';
_loaded = true;
notifyListeners();
}
@@ -231,4 +235,11 @@ class SettingsProvider extends ChangeNotifier {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_lastSeenVersionKey, version);
}
Future<void> setUserName(String name) async {
_userName = name;
notifyListeners();
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_userNameKey, name);
}
}
+145 -81
View File
@@ -2,6 +2,7 @@ import 'package:florid/l10n/app_localizations.dart';
import 'package:florid/models/fdroid_app.dart';
import 'package:florid/screens/library_screen.dart';
import 'package:florid/screens/settings_screen.dart';
import 'package:florid/screens/user_screen.dart';
import 'package:florid/utils/responsive.dart';
import 'package:florid/utils/whats_new.dart';
import 'package:florid/widgets/f_navbar.dart';
@@ -34,6 +35,7 @@ class _FloridAppState extends State<FloridApp> {
const LibraryScreen(),
SearchScreen(tabIndexListenable: _tabNotifier, tabIndex: 1),
const UpdatesScreen(),
const UserScreen(),
];
@override
@@ -176,8 +178,11 @@ class _FloridAppState extends State<FloridApp> {
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.sizeOf(context).width;
final isWide = screenWidth >= Responsive.largeWidth;
return Scaffold(
backgroundColor: MediaQuery.sizeOf(context).width < Responsive.largeWidth
backgroundColor: screenWidth < Responsive.largeWidth
? Theme.of(context).colorScheme.surface
: context.watch<SettingsProvider>().themeStyle == ThemeStyle.florid
? Theme.of(context).colorScheme.surfaceContainer
@@ -204,52 +209,120 @@ class _FloridAppState extends State<FloridApp> {
return icon;
}
final homeIcon = buildIcon(
Symbols.newsstand_rounded,
selected: false,
);
final homeSelectedIcon = buildIcon(
Symbols.newsstand_rounded,
selected: true,
);
final searchIcon = buildIcon(Symbols.search, selected: false);
final searchSelectedIcon = buildIcon(
Symbols.search,
selected: true,
);
final deviceIcon = buildIcon(
Symbols.mobile_3_rounded,
selected: false,
);
final deviceSelectedIcon = buildIcon(
Symbols.mobile_3_rounded,
selected: true,
);
final userIcon = buildIcon(
Symbols.person_rounded,
selected: false,
);
final userSelectedIcon = buildIcon(
Symbols.person_rounded,
selected: true,
);
final floridNavItems = [
FloridNavBarItem(
icon: homeIcon,
selectedIcon: homeSelectedIcon,
label: localizations.home,
),
FloridNavBarItem(
icon: deviceIcon,
selectedIcon: deviceSelectedIcon,
label: localizations.device,
),
FloridNavBarItem(
icon: userIcon,
selectedIcon: userSelectedIcon,
label: "User",
),
];
final navRailDestinations = [
NavigationRailDestination(
icon: homeIcon,
selectedIcon: homeSelectedIcon,
label: Text(localizations.home),
),
if (!isFlorid)
NavigationRailDestination(
icon: searchIcon,
selectedIcon: searchSelectedIcon,
label: Text(localizations.search),
),
NavigationRailDestination(
icon: deviceIcon,
selectedIcon: deviceSelectedIcon,
label: Text(localizations.device),
),
NavigationRailDestination(
icon: userIcon,
selectedIcon: userSelectedIcon,
label: const Text('Me'),
),
];
final floridNavIndex = _currentIndex == 2
? 1
: _currentIndex == 3
? 2
: 0;
final searchFab = FloatingActionButton(
onPressed: () {
setState(() {
_currentIndex = 1;
});
_tabNotifier.value = 1;
},
child: const Icon(Symbols.search),
);
return Stack(
children: [
Row(
children: [
if (MediaQuery.sizeOf(context).width >=
Responsive.largeWidth)
if (isWide)
NavigationRail(
selectedIndex: _currentIndex,
selectedIndex: isFlorid
? floridNavIndex
: _currentIndex,
onDestinationSelected: (index) {
final targetIndex = isFlorid
? (index == 0
? 0
: index == 1
? 2
: 3)
: index;
setState(() {
_currentIndex = index;
_currentIndex = targetIndex;
});
_tabNotifier.value = index;
_tabNotifier.value = targetIndex;
},
destinations: [
NavigationRailDestination(
icon: buildIcon(
Symbols.newsstand_rounded,
selected: false,
),
selectedIcon: buildIcon(
Symbols.newsstand_rounded,
selected: true,
),
label: Text(localizations.home),
),
NavigationRailDestination(
icon: buildIcon(Symbols.search, selected: false),
selectedIcon: buildIcon(
Symbols.search,
selected: true,
),
label: Text(localizations.search),
),
NavigationRailDestination(
icon: buildIcon(
Symbols.mobile_3_rounded,
selected: false,
),
selectedIcon: buildIcon(
Symbols.mobile_3_rounded,
selected: true,
),
label: Text(localizations.device),
),
],
destinations: navRailDestinations,
trailingAtBottom: true,
trailing: Column(
children: [
@@ -288,18 +361,14 @@ class _FloridAppState extends State<FloridApp> {
],
),
),
// For small screens
if (MediaQuery.sizeOf(context).width <
Responsive.largeWidth)
if (!isWide)
Expanded(
child: IndexedStack(
index: _currentIndex,
children: _screens,
),
),
// For big screens
if (MediaQuery.sizeOf(context).width >=
Responsive.largeWidth)
if (isWide)
Expanded(
child: SafeArea(
child: Material(
@@ -315,53 +384,39 @@ class _FloridAppState extends State<FloridApp> {
),
],
),
if (isFlorid &&
MediaQuery.sizeOf(context).width < Responsive.largeWidth)
if (isFlorid && !isWide)
Positioned(
left: 0,
right: 0,
bottom: 16,
child: SafeArea(
child: FNavBar(
currentIndex: _currentIndex,
currentIndex: floridNavIndex,
onChanged: (index) {
final targetIndex = index == 0
? 0
: index == 1
? 2
: 3;
setState(() {
_currentIndex = index;
_currentIndex = targetIndex;
});
_tabNotifier.value = index;
_tabNotifier.value = targetIndex;
},
items: [
FloridNavBarItem(
icon: buildIcon(
Symbols.newsstand_rounded,
selected: false,
),
selectedIcon: buildIcon(
Symbols.newsstand_rounded,
selected: true,
),
label: localizations.home,
),
FloridNavBarItem(
icon: buildIcon(Symbols.search, selected: false),
selectedIcon: buildIcon(
Symbols.search,
selected: true,
),
label: localizations.search,
),
FloridNavBarItem(
icon: buildIcon(
Symbols.mobile_3_rounded,
selected: false,
),
selectedIcon: buildIcon(
Symbols.mobile_3_rounded,
selected: true,
),
label: localizations.device,
),
],
items: floridNavItems,
fab: searchFab,
),
),
),
if (isFlorid && isWide)
Positioned.fill(
child: SafeArea(
child: Align(
alignment: Alignment.bottomRight,
child: Padding(
padding: const EdgeInsets.all(24),
child: searchFab,
),
),
),
),
@@ -426,6 +481,15 @@ class _FloridAppState extends State<FloridApp> {
),
label: localizations.device,
),
const NavigationDestination(
icon: Icon(Symbols.person_rounded),
selectedIcon: Icon(
Symbols.person_rounded,
fill: 1,
weight: 600,
),
label: 'User',
),
];
return NavigationBar(
+58 -4
View File
@@ -2,7 +2,6 @@ import 'dart:convert';
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:florid/l10n/app_localizations.dart';
import 'package:florid/screens/app_management_screen.dart';
import 'package:florid/widgets/m_list.dart';
import 'package:flutter/material.dart';
@@ -181,6 +180,40 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
}
Future<void> _editUserName(
BuildContext context,
SettingsProvider settingsProvider,
) async {
final controller = TextEditingController(text: settingsProvider.userName);
final result = await showDialog<String>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Your name'),
content: TextField(
controller: controller,
autofocus: true,
textInputAction: TextInputAction.done,
decoration: const InputDecoration(hintText: 'Enter your name'),
onSubmitted: (value) => Navigator.of(dialogContext).pop(value),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(controller.text),
child: const Text('Save'),
),
],
),
);
if (result == null) return;
await settingsProvider.setUserName(result.trim());
}
@override
Widget build(BuildContext context) {
return Consumer<SettingsProvider>(
@@ -188,9 +221,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
return Scaffold(
body: CustomScrollView(
slivers: <Widget>[
SliverAppBar.large(
title: Text(AppLocalizations.of(context)!.settings),
),
SliverToBoxAdapter(child: SizedBox(height: 16)),
SliverToBoxAdapter(
child: Column(
@@ -202,6 +232,30 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
MListView(
items: [
MListItemData(
leading: CircleAvatar(
backgroundColor: Theme.of(
context,
).colorScheme.primaryContainer,
foregroundColor: Theme.of(
context,
).colorScheme.onPrimaryContainer,
child: settings.userName.isNotEmpty
? Text(
settings.userName
.trim()
.substring(0, 1)
.toUpperCase(),
)
: Icon(Symbols.person, fill: 1),
),
title: settings.userName.isNotEmpty
? settings.userName
: 'User',
subtitle: 'Set your name',
onTap: () => _editUserName(context, settings),
suffix: Icon(Symbols.edit),
),
MListItemData(
leading: Icon(Symbols.palette),
title: 'Appearance',
-229
View File
@@ -1,7 +1,6 @@
import 'package:florid/l10n/app_localizations.dart';
import 'package:florid/providers/settings_provider.dart';
import 'package:florid/utils/menu_actions.dart';
import 'package:florid/utils/responsive.dart';
import 'package:florid/widgets/changelog_preview.dart';
import 'package:florid/widgets/f_tabbar.dart';
import 'package:flutter/material.dart';
@@ -133,52 +132,8 @@ class _UpdatesScreenState extends State<UpdatesScreen>
context,
).colorScheme.surfaceContainerLow,
title: const Text('Apps'),
actions: [
PopupMenuButton<String>(
onSelected: (value) {
switch (value) {
case 'settings':
MenuActions.showSettings(context);
break;
case 'refresh':
_onRefresh();
case 'about':
MenuActions.showAbout(context);
break;
}
},
itemBuilder: (context) => [
if (!context.isLargeScreen)
PopupMenuItem(
value: 'settings',
child: ListTile(
leading: Icon(Symbols.settings),
title: Text(AppLocalizations.of(context)!.settings),
contentPadding: EdgeInsets.zero,
),
),
PopupMenuItem(
value: 'refresh',
child: ListTile(
leading: Icon(Symbols.refresh),
title: Text(AppLocalizations.of(context)!.refresh),
contentPadding: EdgeInsets.zero,
),
),
PopupMenuItem(
value: 'about',
child: ListTile(
leading: Icon(Symbols.info),
title: Text(AppLocalizations.of(context)!.about),
contentPadding: EdgeInsets.zero,
),
),
],
),
],
bottom: FTabBar(
controller: _tabController,
isScrollable: true,
showBadge: true,
items: [
FloridTabBarItem(
@@ -191,13 +146,6 @@ class _UpdatesScreenState extends State<UpdatesScreen>
label: AppLocalizations.of(context)!.on_device,
badgeCount: allFDroidApps.length,
),
FloridTabBarItem(
icon: Symbols.favorite,
label: AppLocalizations.of(context)!.favourites,
badgeCount: favoriteApps.isNotEmpty
? favoriteApps.length
: 0,
),
],
onTabChanged: (index) {
_tabController.animateTo(index);
@@ -228,18 +176,6 @@ class _UpdatesScreenState extends State<UpdatesScreen>
allFDroidApps,
updatableApps,
),
// Tab 3: Favorites
_buildFavoritesTab(
context,
appProvider,
context.read<SettingsProvider>(),
favoriteApps,
updatableApps,
repositoryLoaded,
repositoryState,
repositoryError,
),
],
),
),
@@ -560,171 +496,6 @@ class _UpdatesScreenState extends State<UpdatesScreen>
);
}
Widget _buildFavoritesTab(
BuildContext context,
AppProvider appProvider,
SettingsProvider settingsProvider,
List<FDroidApp> favoriteApps,
List<FDroidApp> updatableApps,
bool repositoryLoaded,
LoadingState repositoryState,
String? repositoryError,
) {
if (repositoryState == LoadingState.loading && !repositoryLoaded) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
CircularProgressIndicator(year2023: false),
SizedBox(height: 12),
Text('Loading repository…'),
],
),
);
}
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 (favoriteApps.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.favorite_rounded,
size: 64,
fill: 1,
color: Colors.pink[600],
).animate().scaleXY(
delay: Duration(milliseconds: 100),
duration: 500.ms,
curve: Curves.elasticInOut,
),
const SizedBox(height: 16),
Text(
'No favourites yet',
style: Theme.of(context).textTheme.headlineSmall,
)
.animate()
.scaleXY(delay: Duration(milliseconds: 100), duration: 200.ms)
.fade(delay: Duration(milliseconds: 100), duration: 500.ms),
const SizedBox(height: 8),
Text(
'Tap the star on any app to save it here',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
)
.animate()
.scaleXY(delay: Duration(milliseconds: 300), duration: 200.ms)
.fade(delay: Duration(milliseconds: 200), duration: 500.ms),
],
),
),
);
}
final bottomPadding = settingsProvider.themeStyle == ThemeStyle.florid
? 96.0
: 16.0;
return ListView.builder(
padding: EdgeInsets.fromLTRB(8, 8, 8, bottomPadding),
itemCount: favoriteApps.length,
itemBuilder: (context, index) {
final app = favoriteApps[index];
final hasUpdate = updatableApps.any(
(updateApp) => updateApp.packageName == app.packageName,
);
return Card(
elevation: 0,
child: Column(
children: [
AppListItem(
app: app,
showInstallStatus: true,
showFavorite: 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>();
+394
View File
@@ -0,0 +1,394 @@
import 'package:florid/l10n/app_localizations.dart';
import 'package:florid/models/fdroid_app.dart';
import 'package:florid/providers/app_provider.dart';
import 'package:florid/providers/download_provider.dart';
import 'package:florid/providers/settings_provider.dart';
import 'package:florid/screens/app_details_screen.dart';
import 'package:florid/screens/settings_screen.dart';
import 'package:florid/utils/menu_actions.dart';
import 'package:florid/widgets/app_list_item.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';
class UserScreen extends StatefulWidget {
const UserScreen({super.key});
@override
State<UserScreen> createState() => _UserScreenState();
}
class _UserScreenState extends State<UserScreen>
with SingleTickerProviderStateMixin {
late final TabController _tabController;
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
void _loadData() {
final appProvider = context.read<AppProvider>();
// Load both repository data and installed apps
appProvider.fetchRepository();
appProvider.fetchInstalledApps();
}
Widget _buildFavoritesTab(
BuildContext context,
AppProvider appProvider,
SettingsProvider settingsProvider,
List<FDroidApp> favoriteApps,
List<FDroidApp> updatableApps,
bool repositoryLoaded,
LoadingState repositoryState,
String? repositoryError,
) {
if (repositoryState == LoadingState.loading && !repositoryLoaded) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
CircularProgressIndicator(year2023: false),
SizedBox(height: 12),
Text('Loading repository…'),
],
),
);
}
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 (favoriteApps.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.favorite_rounded,
size: 64,
fill: 1,
color: Colors.pink[600],
).animate().scaleXY(
delay: Duration(milliseconds: 100),
duration: 500.ms,
curve: Curves.elasticInOut,
),
const SizedBox(height: 16),
Text(
'No favourites yet',
style: Theme.of(context).textTheme.headlineSmall,
)
.animate()
.scaleXY(delay: Duration(milliseconds: 100), duration: 200.ms)
.fade(delay: Duration(milliseconds: 100), duration: 500.ms),
const SizedBox(height: 8),
Text(
'Tap the star on any app to save it here',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
)
.animate()
.scaleXY(delay: Duration(milliseconds: 300), duration: 200.ms)
.fade(delay: Duration(milliseconds: 200), duration: 500.ms),
],
),
),
);
}
final bottomPadding = settingsProvider.themeStyle == ThemeStyle.florid
? 96.0
: 16.0;
return ListView.builder(
padding: EdgeInsets.fromLTRB(8, 8, 8, bottomPadding),
itemCount: favoriteApps.length,
itemBuilder: (context, index) {
final app = favoriteApps[index];
final hasUpdate = updatableApps.any(
(updateApp) => updateApp.packageName == app.packageName,
);
return Card(
elevation: 0,
child: Column(
children: [
AppListItem(
app: app,
showInstallStatus: true,
showFavorite: 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')));
}
}
}
}
@override
Widget build(BuildContext context) {
final appProvider = context.watch<AppProvider>();
final settingsProvider = context.watch<SettingsProvider>();
final repositoryLoaded = appProvider.repository != null;
return Scaffold(
body: NestedScrollView(
headerSliverBuilder: (context, innerBoxIsScrolled) => [
SliverAppBar.medium(
backgroundColor: Theme.of(context).colorScheme.surfaceContainerLow,
surfaceTintColor: Theme.of(context).colorScheme.surfaceContainerLow,
title: Row(
children: [
CircleAvatar(
backgroundColor: Theme.of(
context,
).colorScheme.primaryContainer,
foregroundColor: Theme.of(
context,
).colorScheme.onPrimaryContainer,
child: settingsProvider.userName.isNotEmpty
? Text(
settingsProvider.userName
.trim()
.substring(0, 1)
.toUpperCase(),
)
: const Icon(Symbols.person),
),
const SizedBox(width: 12),
Text(
settingsProvider.userName.isNotEmpty
? settingsProvider.userName
: 'User',
),
],
),
),
SliverPersistentHeader(
pinned: true,
delegate: _FTabBarHeaderDelegate(
height: settingsProvider.themeStyle == ThemeStyle.florid
? 68
: 56,
child: Material(
color: Theme.of(context).colorScheme.surfaceContainerLow,
surfaceTintColor: Theme.of(
context,
).colorScheme.surfaceContainerLow,
child: Container(
margin: settingsProvider.themeStyle == ThemeStyle.florid
? const EdgeInsets.only(top: 8)
: null,
child: FTabBar(
controller: _tabController,
onTabChanged: (index) {
_tabController.animateTo(index);
},
items: [
FloridTabBarItem(
icon: Symbols.favorite_rounded,
label: AppLocalizations.of(context)!.favourites,
),
FloridTabBarItem(
icon: Symbols.settings,
label: AppLocalizations.of(context)!.settings,
),
],
),
),
),
),
),
],
body: TabBarView(
controller: _tabController,
children: [
FutureBuilder<List<FDroidApp>>(
future: repositoryLoaded
? appProvider.getUpdatableApps()
: Future.value(<FDroidApp>[]),
builder: (context, snapshot) {
final updatableApps = snapshot.data ?? <FDroidApp>[];
final favoriteApps = repositoryLoaded
? appProvider.getFavoriteApps()
: <FDroidApp>[];
return _buildFavoritesTab(
context,
appProvider,
settingsProvider,
favoriteApps,
updatableApps,
repositoryLoaded,
appProvider.repositoryState,
appProvider.repositoryError,
);
},
),
Padding(
padding: EdgeInsets.fromLTRB(
0,
16,
0,
settingsProvider.themeStyle == ThemeStyle.florid ? 32 : 0,
),
child: SettingsScreen(),
),
],
),
),
);
}
}
class _FTabBarHeaderDelegate extends SliverPersistentHeaderDelegate {
_FTabBarHeaderDelegate({required this.height, required this.child});
final double height;
final Widget child;
@override
double get minExtent => height;
@override
double get maxExtent => height;
@override
Widget build(
BuildContext context,
double shrinkOffset,
bool overlapsContent,
) {
return SizedBox.expand(child: child);
}
@override
bool shouldRebuild(covariant _FTabBarHeaderDelegate oldDelegate) {
return oldDelegate.height != height || oldDelegate.child != child;
}
}
+85 -65
View File
@@ -7,6 +7,9 @@ class FNavBar extends StatelessWidget {
required this.items,
required this.currentIndex,
required this.onChanged,
this.fab,
this.fabGap = 4,
this.fabPadding = const EdgeInsets.only(left: 8),
this.margin = const EdgeInsets.fromLTRB(16, 0, 16, 12),
this.height = 64,
});
@@ -14,6 +17,9 @@ class FNavBar extends StatelessWidget {
final List<FloridNavBarItem> items;
final int currentIndex;
final ValueChanged<int> onChanged;
final Widget? fab;
final double fabGap;
final EdgeInsets fabPadding;
final EdgeInsets margin;
final double height;
@@ -29,80 +35,94 @@ class FNavBar extends StatelessWidget {
top: false,
child: Padding(
padding: margin,
child: Material(
color: baseColor,
elevation: 1,
borderRadius: BorderRadius.circular(999),
child: SizedBox(
height: height,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
children: List.generate(items.length, (index) {
final item = items[index];
final selected = index == currentIndex;
return Expanded(
flex: selected ? 2 : 1,
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOut,
decoration: BoxDecoration(
color: selected ? accentColor : Colors.transparent,
borderRadius: BorderRadius.circular(999),
),
child: InkWell(
onTap: () => onChanged(index),
borderRadius: BorderRadius.circular(99),
child: SizedBox(
height: double.infinity,
child: Row(
spacing: 16.0,
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconTheme(
data: IconThemeData(
color: selected
? selectedColor
: unselectedColor,
),
child: selected ? item.selectedIcon : item.icon,
),
if (selected)
AnimatedDefaultTextStyle(
duration: const Duration(
milliseconds: 180,
),
curve: Curves.easeOut,
style: TextStyle(
child: Row(
children: [
Expanded(
child: Material(
color: baseColor,
elevation: 1,
borderRadius: BorderRadius.circular(999),
child: SizedBox(
height: height,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
children: List.generate(items.length, (index) {
final item = items[index];
final selected = index == currentIndex;
return Expanded(
flex: selected ? 2 : 1,
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOut,
decoration: BoxDecoration(
color: selected
? accentColor
: Colors.transparent,
borderRadius: BorderRadius.circular(999),
),
child: InkWell(
onTap: () => onChanged(index),
borderRadius: BorderRadius.circular(99),
child: SizedBox(
height: double.infinity,
child: Row(
spacing: 16.0,
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconTheme(
data: IconThemeData(
color: selected
? selectedColor
: unselectedColor,
fontSize: 14,
fontVariations: [
FontVariation('ROND', 100),
],
),
child: Text(item.label),
)
.animate()
.fadeIn(duration: 180.ms)
.slideX(
begin: 0.5,
end: 0,
duration: 180.ms,
curve: Curves.easeOut,
child: selected
? item.selectedIcon
: item.icon,
),
],
if (selected)
AnimatedDefaultTextStyle(
duration: const Duration(
milliseconds: 180,
),
curve: Curves.easeOut,
style: TextStyle(
color: selected
? selectedColor
: unselectedColor,
fontSize: 14,
fontVariations: [
FontVariation('ROND', 100),
],
),
child: Text(item.label),
)
.animate()
.fadeIn(duration: 180.ms)
.slideX(
begin: 0.5,
end: 0,
duration: 180.ms,
curve: Curves.easeOut,
),
],
),
),
),
),
),
),
);
}),
),
);
}),
),
),
),
),
),
if (fab != null) ...[
SizedBox(width: fabGap),
Padding(padding: fabPadding, child: fab!),
],
],
),
),
);