[feat]: Show what's new for Florid
This commit is contained in:
@@ -20,6 +20,7 @@ class SettingsProvider extends ChangeNotifier {
|
||||
static const backgroundUpdatesKey = 'background_updates_enabled';
|
||||
static const updateIntervalHoursKey = 'background_update_interval_hours';
|
||||
static const updateNetworkPolicyKey = 'background_update_network_policy';
|
||||
static const _lastSeenVersionKey = 'last_seen_version';
|
||||
|
||||
ThemeMode _themeMode = ThemeMode.system;
|
||||
ThemeStyle _themeStyle = ThemeStyle.florid;
|
||||
@@ -34,6 +35,7 @@ class SettingsProvider extends ChangeNotifier {
|
||||
int _updateIntervalHours = 6;
|
||||
UpdateNetworkPolicy _updateNetworkPolicy = UpdateNetworkPolicy.any;
|
||||
bool _loaded = false;
|
||||
String _lastSeenVersion = '';
|
||||
|
||||
SettingsProvider() {
|
||||
_load();
|
||||
@@ -52,6 +54,7 @@ class SettingsProvider extends ChangeNotifier {
|
||||
bool get backgroundUpdatesEnabled => _backgroundUpdatesEnabled;
|
||||
int get updateIntervalHours => _updateIntervalHours;
|
||||
UpdateNetworkPolicy get updateNetworkPolicy => _updateNetworkPolicy;
|
||||
String get lastSeenVersion => _lastSeenVersion;
|
||||
|
||||
/// Available locales for F-Droid repository data
|
||||
static const List<String> availableLocales = [
|
||||
@@ -130,6 +133,7 @@ class SettingsProvider extends ChangeNotifier {
|
||||
if (policyIndex >= 0 && policyIndex < UpdateNetworkPolicy.values.length) {
|
||||
_updateNetworkPolicy = UpdateNetworkPolicy.values[policyIndex];
|
||||
}
|
||||
_lastSeenVersion = prefs.getString(_lastSeenVersionKey) ?? '';
|
||||
_loaded = true;
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -220,4 +224,11 @@ class SettingsProvider extends ChangeNotifier {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt(updateNetworkPolicyKey, policy.index);
|
||||
}
|
||||
|
||||
Future<void> setLastSeenVersion(String version) async {
|
||||
_lastSeenVersion = version;
|
||||
notifyListeners();
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_lastSeenVersionKey, version);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,12 @@ import 'package:florid/models/fdroid_app.dart';
|
||||
import 'package:florid/screens/library_screen.dart';
|
||||
import 'package:florid/screens/settings_screen.dart';
|
||||
import 'package:florid/utils/responsive.dart';
|
||||
import 'package:florid/utils/whats_new.dart';
|
||||
import 'package:florid/widgets/f_navbar.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../providers/app_provider.dart';
|
||||
@@ -24,6 +27,8 @@ class FloridApp extends StatefulWidget {
|
||||
class _FloridAppState extends State<FloridApp> {
|
||||
int _currentIndex = 0;
|
||||
final ValueNotifier<int> _tabNotifier = ValueNotifier<int>(0);
|
||||
bool _hasCheckedWhatsNew = false;
|
||||
bool _isShowingWhatsNew = false;
|
||||
|
||||
late final List<Widget> _screens = [
|
||||
const LibraryScreen(),
|
||||
@@ -41,9 +46,128 @@ class _FloridAppState extends State<FloridApp> {
|
||||
|
||||
appProvider.fetchInstalledApps();
|
||||
repositoriesProvider.loadRepositories();
|
||||
_maybeShowWhatsNewDialog();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _maybeShowWhatsNewDialog() async {
|
||||
if (_hasCheckedWhatsNew) return;
|
||||
_hasCheckedWhatsNew = true;
|
||||
await _showWhatsNew(force: false, markSeen: true);
|
||||
}
|
||||
|
||||
Future<void> _showWhatsNew({
|
||||
required bool force,
|
||||
required bool markSeen,
|
||||
}) async {
|
||||
if (_isShowingWhatsNew) return;
|
||||
_isShowingWhatsNew = true;
|
||||
|
||||
final settings = context.read<SettingsProvider>();
|
||||
if (!settings.isLoaded) {
|
||||
_isShowingWhatsNew = false;
|
||||
return;
|
||||
}
|
||||
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
final currentVersion = '${info.version}+${info.buildNumber}';
|
||||
if (!force && settings.lastSeenVersion == currentVersion) {
|
||||
_isShowingWhatsNew = false;
|
||||
return;
|
||||
}
|
||||
|
||||
final whatsNew = await WhatsNewLoader.loadForVersion(currentVersion);
|
||||
if (!mounted) {
|
||||
_isShowingWhatsNew = false;
|
||||
return;
|
||||
}
|
||||
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
useSafeArea: true,
|
||||
builder: (context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 32.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
spacing: 24,
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
spacing: 16,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"What's new in $currentVersion",
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 12,
|
||||
children: _buildWhatsNewContent(context, whatsNew),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
FilledButton(onPressed: () {}, child: Text("Close")),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (markSeen) {
|
||||
await settings.setLastSeenVersion(currentVersion);
|
||||
}
|
||||
|
||||
_isShowingWhatsNew = false;
|
||||
}
|
||||
|
||||
static Future<void> triggerWhatsNew(
|
||||
BuildContext context, {
|
||||
bool markSeen = true,
|
||||
}) async {
|
||||
final state = context.findAncestorStateOfType<_FloridAppState>();
|
||||
if (state != null) {
|
||||
await state._showWhatsNew(force: true, markSeen: markSeen);
|
||||
}
|
||||
}
|
||||
|
||||
List<Widget> _buildWhatsNewContent(BuildContext context, WhatsNewData? data) {
|
||||
if (data == null || data.sections.isEmpty) {
|
||||
return const [
|
||||
Text('Thanks for updating Florid!'),
|
||||
Text('Enjoy the latest improvements.'),
|
||||
];
|
||||
}
|
||||
|
||||
final titleStyle = Theme.of(context).textTheme.titleMedium;
|
||||
|
||||
return data.sections
|
||||
.map(
|
||||
(section) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 8,
|
||||
children: [
|
||||
Text(section.title, style: titleStyle),
|
||||
...section.items.map(
|
||||
(item) => Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('• '),
|
||||
Expanded(child: Text(item)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabNotifier.dispose();
|
||||
@@ -58,6 +182,13 @@ class _FloridAppState extends State<FloridApp> {
|
||||
: context.watch<SettingsProvider>().themeStyle == ThemeStyle.florid
|
||||
? Theme.of(context).colorScheme.surfaceContainer
|
||||
: Theme.of(context).colorScheme.surface,
|
||||
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () {
|
||||
_showWhatsNew(force: true, markSeen: false);
|
||||
},
|
||||
child: Text('s'),
|
||||
),
|
||||
body: Consumer2<AppProvider, SettingsProvider>(
|
||||
builder: (context, appProvider, settings, child) {
|
||||
return FutureBuilder<List<FDroidApp>>(
|
||||
@@ -152,6 +283,14 @@ class _FloridAppState extends State<FloridApp> {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (kDebugMode)
|
||||
TextButton(
|
||||
onPressed: () => _showWhatsNew(
|
||||
force: true,
|
||||
markSeen: false,
|
||||
),
|
||||
child: const Text("Show what's new"),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
class WhatsNewSection {
|
||||
WhatsNewSection({required this.title, required this.items});
|
||||
|
||||
final String title;
|
||||
final List<String> items;
|
||||
}
|
||||
|
||||
class WhatsNewData {
|
||||
WhatsNewData({required this.version, required this.sections});
|
||||
|
||||
final String version;
|
||||
final List<WhatsNewSection> sections;
|
||||
}
|
||||
|
||||
class WhatsNewLoader {
|
||||
static Future<WhatsNewData?> loadForVersion(String version) async {
|
||||
try {
|
||||
final changelog = await _loadChangelog();
|
||||
if (changelog == null || changelog.trim().isEmpty) {
|
||||
debugPrint('[WhatsNew] Changelog asset missing or empty');
|
||||
return null;
|
||||
}
|
||||
final candidates = <String>{version};
|
||||
final withoutBuild = version.contains('+')
|
||||
? version.substring(0, version.indexOf('+'))
|
||||
: null;
|
||||
if (withoutBuild != null) {
|
||||
candidates.add(withoutBuild);
|
||||
}
|
||||
|
||||
WhatsNewData? parsed;
|
||||
for (final candidate in candidates) {
|
||||
final block = _extractBlock(changelog, candidate);
|
||||
if (block != null) {
|
||||
parsed = _parseBlock(candidate, block);
|
||||
if (parsed != null) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed != null) return parsed;
|
||||
|
||||
// Fallback: show the latest entry in the changelog.
|
||||
final firstHeading = RegExp(
|
||||
'^##\\s+v?([^\\s]+)',
|
||||
multiLine: true,
|
||||
).firstMatch(changelog);
|
||||
if (firstHeading == null) return null;
|
||||
|
||||
final latestVersion = firstHeading.group(1)!;
|
||||
final latestBlock = _extractBlock(changelog, latestVersion);
|
||||
return latestBlock != null
|
||||
? _parseBlock(latestVersion, latestBlock)
|
||||
: null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<String?> _loadChangelog() async {
|
||||
const candidates = [
|
||||
'CHANGELOGS.md',
|
||||
'assets/CHANGELOGS.md',
|
||||
'assets/changelogs.md',
|
||||
];
|
||||
for (final path in candidates) {
|
||||
try {
|
||||
final data = await rootBundle.loadString(path);
|
||||
debugPrint('[WhatsNew] Loaded changelog from $path');
|
||||
return data;
|
||||
} catch (_) {
|
||||
// Try next candidate
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String? _extractBlock(String changelog, String version) {
|
||||
final headingPattern = RegExp(
|
||||
'^##\\s+v?${RegExp.escape(version)}\\s*\$',
|
||||
multiLine: true,
|
||||
);
|
||||
final headingMatch = headingPattern.firstMatch(changelog);
|
||||
if (headingMatch == null) return null;
|
||||
|
||||
final nextHeadingPattern = RegExp('^##\\s+v', multiLine: true);
|
||||
final laterHeadings = nextHeadingPattern
|
||||
.allMatches(changelog)
|
||||
.where((m) => m.start > headingMatch.start)
|
||||
.toList();
|
||||
final endIndex = laterHeadings.isNotEmpty
|
||||
? laterHeadings.first.start
|
||||
: changelog.length;
|
||||
|
||||
final block = changelog.substring(headingMatch.end, endIndex).trim();
|
||||
return block.isEmpty ? null : block;
|
||||
}
|
||||
|
||||
static WhatsNewData? _parseBlock(String version, String block) {
|
||||
debugPrint('[WhatsNew] Block length=${block.length}');
|
||||
final previewEnd = block.length.clamp(0, 200);
|
||||
debugPrint('[WhatsNew] Block preview=${block.substring(0, previewEnd)}');
|
||||
|
||||
final lines = const LineSplitter().convert(block);
|
||||
debugPrint('[WhatsNew] Line count=${lines.length}');
|
||||
final sections = <WhatsNewSection>[];
|
||||
String? currentTitle;
|
||||
final currentItems = <String>[];
|
||||
|
||||
void pushSection() {
|
||||
if ((currentTitle != null && currentItems.isNotEmpty) ||
|
||||
(currentTitle == null && currentItems.isNotEmpty)) {
|
||||
sections.add(
|
||||
WhatsNewSection(
|
||||
title: currentTitle ?? 'Changes',
|
||||
items: List.unmodifiable(currentItems),
|
||||
),
|
||||
);
|
||||
}
|
||||
currentItems.clear();
|
||||
}
|
||||
|
||||
for (final line in lines) {
|
||||
if (line.startsWith('### ')) {
|
||||
pushSection();
|
||||
currentTitle = line.substring(4).trim();
|
||||
continue;
|
||||
}
|
||||
final trimmed = line.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('- ')) {
|
||||
currentItems.add(line.substring(2).trim());
|
||||
} else {
|
||||
// Treat free text (e.g., intro sentences) as bullet items so they show up.
|
||||
currentItems.add(trimmed);
|
||||
}
|
||||
}
|
||||
pushSection();
|
||||
|
||||
if (sections.isNotEmpty) {
|
||||
final itemCount = sections.fold<int>(0, (sum, s) => sum + s.items.length);
|
||||
debugPrint(
|
||||
'[WhatsNew] Parsed version=$version sections=${sections.length} items=$itemCount',
|
||||
);
|
||||
} else {
|
||||
debugPrint('[WhatsNew] Parsed version=$version with no sections/items');
|
||||
}
|
||||
|
||||
if (sections.isEmpty) return null;
|
||||
return WhatsNewData(version: version, sections: sections);
|
||||
}
|
||||
}
|
||||
@@ -120,6 +120,7 @@ flutter:
|
||||
- assets/Foreground.png
|
||||
- assets/Splash.png
|
||||
- assets/repositories.json
|
||||
- CHANGELOGS.md
|
||||
# - images/a_dot_burr.jpeg
|
||||
# - images/a_dot_ham.jpeg
|
||||
|
||||
|
||||
Reference in New Issue
Block a user