[feat]: User page
This commit is contained in:
@@ -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
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>();
|
||||
|
||||
|
||||
@@ -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
@@ -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!),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user