[feat]: Show what's new for Florid

This commit is contained in:
nahnah
2026-02-14 14:22:35 +00:00
parent bf1aa3b32e
commit 8930bddacc
4 changed files with 309 additions and 0 deletions
+11
View File
@@ -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);
}
}
+139
View File
@@ -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),
],
),
+158
View File
@@ -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);
}
}
+1
View File
@@ -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