feat: update checker — controllo aggiornamenti all'avvio

Merge branch 'update-checker'

All'avvio l'app confronta silenziosamente la versione installata con
l'ultima release GitHub. Se disponibile una versione più recente,
mostra un dialog con pulsante "Scarica" che apre releases/latest
nel browser. Testato su device fisico Android.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 08:44:16 +02:00
11 changed files with 339 additions and 28 deletions
+3
View File
@@ -10,6 +10,9 @@ dist/
/build/ /build/
*.g.dart *.g.dart
# Generato da flutter create — non appartiene al progetto
test/widget_test.dart
# Piattaforme non usate # Piattaforme non usate
/ios/ /ios/
/linux/ /linux/
+17 -14
View File
@@ -84,24 +84,27 @@ lib/
## Testing ## Testing
110 test (unit + widget). Non richiedono device fisico. 130 test (unit + widget). Non richiedono device fisico.
``` ```
test/ test/
├── helpers/pump_app.dart # estensione pumpApp per widget test ├── helpers/pump_app.dart # estensione pumpApp per widget test
── features/ ── features/
├── converter/ ├── converter/
│ ├── models/conversion_entry_test.dart │ ├── models/conversion_entry_test.dart
│ └── providers/converter_provider_test.dart │ └── providers/converter_provider_test.dart
├── meal_planner/ ├── meal_planner/
│ ├── models/meal_plan_test.dart │ ├── models/meal_plan_test.dart
│ ├── providers/meal_planner_provider_test.dart │ ├── providers/meal_planner_provider_test.dart
│ ├── widgets/meal_card_test.dart │ ├── widgets/meal_card_test.dart
│ └── qr_test.dart │ └── qr_test.dart
└── shopping_list/ └── shopping_list/
├── models/shopping_item_test.dart ├── models/shopping_item_test.dart
├── providers/shopping_list_provider_test.dart ├── providers/shopping_list_provider_test.dart
└── widgets/shopping_item_tile_test.dart └── widgets/shopping_item_tile_test.dart
└── shared/
├── services/update_service_test.dart
└── widgets/update_dialog_test.dart
``` ```
I test usano `SharedPreferences.setMockInitialValues({})` per isolare lo storage. I test usano `SharedPreferences.setMockInitialValues({})` per isolare lo storage.
+22 -13
View File
@@ -24,6 +24,10 @@ App Android per la gestione della dieta quotidiana — pianificazione pasti, con
- Separazione visiva tra elementi da completare e completati - Separazione visiva tra elementi da completare e completati
- Rimozione singola e svuota lista con conferma - Rimozione singola e svuota lista con conferma
### Controllo aggiornamenti
- All'avvio controlla silenziosamente l'ultima release su GitHub
- Se disponibile una versione più recente, mostra un dialog con link diretto al download dell'APK
## Stack ## Stack
| Livello | Tecnologia | | Livello | Tecnologia |
@@ -74,19 +78,24 @@ docker run --rm -v "$(pwd):/workspace" -w /workspace biteplan-build \
test/ test/
├── helpers/ ├── helpers/
│ └── pump_app.dart # estensione pumpApp per widget test │ └── pump_app.dart # estensione pumpApp per widget test
── features/ ── features/
├── converter/ ├── converter/
│ ├── models/conversion_entry_test.dart │ ├── models/conversion_entry_test.dart
│ └── providers/converter_provider_test.dart │ └── providers/converter_provider_test.dart
├── meal_planner/ ├── meal_planner/
│ ├── models/meal_plan_test.dart │ ├── models/meal_plan_test.dart
│ ├── providers/meal_planner_provider_test.dart │ ├── providers/meal_planner_provider_test.dart
│ ├── widgets/meal_card_test.dart │ ├── widgets/meal_card_test.dart
│ └── qr_test.dart │ └── qr_test.dart
└── shopping_list/ └── shopping_list/
├── models/shopping_item_test.dart ├── models/shopping_item_test.dart
├── providers/shopping_list_provider_test.dart ├── providers/shopping_list_provider_test.dart
└── widgets/shopping_item_tile_test.dart └── widgets/shopping_item_tile_test.dart
└── shared/
├── services/
│ └── update_service_test.dart # parsing versione e confronto semver
└── widgets/
└── update_dialog_test.dart # widget test dialog aggiornamento
``` ```
## Build APK ## Build APK
+5
View File
@@ -1,5 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.INTERNET" />
<application <application
android:label="BitePlan" android:label="BitePlan"
android:name="${applicationName}" android:name="${applicationName}"
@@ -42,5 +43,9 @@
<action android:name="android.intent.action.PROCESS_TEXT"/> <action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/> <data android:mimeType="text/plain"/>
</intent> </intent>
<intent>
<action android:name="android.intent.action.VIEW"/>
<data android:scheme="https"/>
</intent>
</queries> </queries>
</manifest> </manifest>
@@ -1,5 +1,29 @@
package com.davide.biteplan package com.davide.biteplan
import android.content.Intent
import android.net.Uri
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity() class MainActivity : FlutterActivity() {
private val channel = "com.davide.biteplan/launcher"
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channel)
.setMethodCallHandler { call, result ->
if (call.method == "launch") {
val url = call.argument<String>("url")
if (url != null) {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
result.success(null)
} else {
result.error("INVALID_URL", "URL is null", null)
}
} else {
result.notImplemented()
}
}
}
}
+7
View File
@@ -4,6 +4,8 @@ import 'features/meal_planner/presentation/pages/meal_planner_page.dart';
import 'features/converter/presentation/pages/converter_page.dart'; import 'features/converter/presentation/pages/converter_page.dart';
import 'features/shopping_list/presentation/pages/shopping_list_page.dart'; import 'features/shopping_list/presentation/pages/shopping_list_page.dart';
import 'features/guide/presentation/widgets/info_bottom_sheet.dart'; import 'features/guide/presentation/widgets/info_bottom_sheet.dart';
import 'shared/services/update_service.dart';
import 'shared/widgets/update_dialog.dart';
class BitePlanApp extends StatelessWidget { class BitePlanApp extends StatelessWidget {
const BitePlanApp({super.key}); const BitePlanApp({super.key});
@@ -42,6 +44,11 @@ class _MainScaffoldState extends State<_MainScaffold> {
const ConverterPage(), const ConverterPage(),
const ShoppingListPage(), const ShoppingListPage(),
]; ];
UpdateService.checkUpdate().then((newVersion) {
if (newVersion != null && mounted) {
showUpdateDialog(context, newVersion);
}
});
} }
@override @override
+65
View File
@@ -0,0 +1,65 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart' show kIsWeb, visibleForTesting;
import '../../core/constants/app_constants.dart';
class UpdateService {
static const _apiUrl =
'https://api.github.com/repos/davide3011/biteplan/releases/latest';
/// Returns the latest version string if it's newer than the current version,
/// or null if already up to date or if the check fails.
static Future<String?> checkUpdate() async {
if (kIsWeb) return null;
try {
final client = HttpClient()
..connectionTimeout = const Duration(seconds: 5);
final request = await client
.getUrl(Uri.parse(_apiUrl))
.timeout(const Duration(seconds: 5));
request.headers
..set('Accept', 'application/vnd.github+json')
..set('User-Agent', 'biteplan/$kAppVersion');
final response =
await request.close().timeout(const Duration(seconds: 5));
final body = await response.transform(utf8.decoder).join();
client.close();
if (response.statusCode != 200) return null;
final tag = parseTagName(body);
if (tag == null) return null;
return isNewer(tag, kAppVersion) ? tag : null;
} catch (_) {
return null;
}
}
@visibleForTesting
static String? parseTagName(String body) {
try {
final data = jsonDecode(body) as Map<String, dynamic>;
return (data['tag_name'] as String).replaceFirst(RegExp(r'^v'), '');
} catch (_) {
return null;
}
}
@visibleForTesting
static bool isNewer(String remote, String local) {
final r = parseVersion(remote);
final l = parseVersion(local);
for (var i = 0; i < 3; i++) {
if (r[i] > l[i]) return true;
if (r[i] < l[i]) return false;
}
return false;
}
@visibleForTesting
static List<int> parseVersion(String v) {
final parts = v.split('.');
return List.generate(
3, (i) => i < parts.length ? (int.tryParse(parts[i]) ?? 0) : 0);
}
}
@@ -0,0 +1,9 @@
import 'package:flutter/services.dart';
class UrlLauncherService {
static const _channel = MethodChannel('com.davide.biteplan/launcher');
static Future<void> launch(String url) async {
await _channel.invokeMethod<void>('launch', {'url': url});
}
}
+31
View File
@@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
import '../services/url_launcher_service.dart';
const _releasesUrl = 'https://github.com/davide3011/biteplan/releases/latest';
void showUpdateDialog(BuildContext context, String newVersion) {
showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
icon: const Icon(Icons.system_update_outlined, size: 32),
title: const Text('Aggiornamento disponibile'),
content: Text(
'È disponibile la versione $newVersion di BitePlan.\n'
'Vuoi scaricare il nuovo APK?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Più tardi'),
),
FilledButton(
onPressed: () {
Navigator.of(ctx).pop();
UrlLauncherService.launch(_releasesUrl);
},
child: const Text('Scarica'),
),
],
),
);
}
@@ -0,0 +1,73 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:biteplan/shared/services/update_service.dart';
void main() {
group('UpdateService.parseTagName', () {
test('strips v prefix from tag_name', () {
expect(UpdateService.parseTagName('{"tag_name":"v2.1.0"}'), '2.1.0');
});
test('works without v prefix', () {
expect(UpdateService.parseTagName('{"tag_name":"2.1.0"}'), '2.1.0');
});
test('returns null for malformed JSON', () {
expect(UpdateService.parseTagName('not json'), isNull);
});
test('returns null when tag_name is missing', () {
expect(UpdateService.parseTagName('{"message":"Not Found"}'), isNull);
});
test('returns null for empty body', () {
expect(UpdateService.parseTagName(''), isNull);
});
});
group('UpdateService.parseVersion', () {
test('parses standard semver', () {
expect(UpdateService.parseVersion('2.1.3'), [2, 1, 3]);
});
test('pads missing parts with zeros', () {
expect(UpdateService.parseVersion('2.1'), [2, 1, 0]);
expect(UpdateService.parseVersion('2'), [2, 0, 0]);
});
test('handles non-numeric parts as zero', () {
expect(UpdateService.parseVersion('2.x.3'), [2, 0, 3]);
});
});
group('UpdateService.isNewer', () {
test('patch bump is newer', () {
expect(UpdateService.isNewer('2.0.1', '2.0.0'), isTrue);
});
test('minor bump is newer', () {
expect(UpdateService.isNewer('2.1.0', '2.0.9'), isTrue);
});
test('major bump is newer', () {
expect(UpdateService.isNewer('3.0.0', '2.9.9'), isTrue);
});
test('same version is not newer', () {
expect(UpdateService.isNewer('2.0.0', '2.0.0'), isFalse);
});
test('older remote is not newer', () {
expect(UpdateService.isNewer('1.9.9', '2.0.0'), isFalse);
});
test('remote with v-prefix stripped is handled correctly', () {
// tag_name comes in already stripped of 'v' by checkUpdate
expect(UpdateService.isNewer('2.0.1', '2.0.0'), isTrue);
});
test('multi-digit version components', () {
expect(UpdateService.isNewer('2.0.10', '2.0.9'), isTrue);
expect(UpdateService.isNewer('2.10.0', '2.9.0'), isTrue);
});
});
}
@@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:biteplan/shared/widgets/update_dialog.dart';
import '../../helpers/pump_app.dart';
void main() {
setUp(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel('com.davide.biteplan/launcher'),
(call) async => null,
);
});
tearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel('com.davide.biteplan/launcher'),
null,
);
});
Future<void> openDialog(WidgetTester tester, String version) async {
await tester.pumpApp(
Builder(
builder: (ctx) => TextButton(
onPressed: () => showUpdateDialog(ctx, version),
child: const Text('apri'),
),
),
);
await tester.tap(find.text('apri'));
await tester.pumpAndSettle();
}
testWidgets('mostra il titolo corretto', (tester) async {
await openDialog(tester, '2.1.0');
expect(find.text('Aggiornamento disponibile'), findsOneWidget);
});
testWidgets('mostra la versione nel contenuto', (tester) async {
await openDialog(tester, '2.1.0');
expect(find.textContaining('2.1.0'), findsOneWidget);
});
testWidgets('ha i pulsanti Più tardi e Scarica', (tester) async {
await openDialog(tester, '2.1.0');
expect(find.text('Più tardi'), findsOneWidget);
expect(find.text('Scarica'), findsOneWidget);
});
testWidgets('Più tardi chiude il dialog', (tester) async {
await openDialog(tester, '2.1.0');
await tester.tap(find.text('Più tardi'));
await tester.pumpAndSettle();
expect(find.text('Aggiornamento disponibile'), findsNothing);
});
testWidgets('Scarica chiude il dialog e chiama il launcher', (tester) async {
final calls = <MethodCall>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel('com.davide.biteplan/launcher'),
(call) async {
calls.add(call);
return null;
},
);
await openDialog(tester, '2.1.0');
await tester.tap(find.text('Scarica'));
await tester.pumpAndSettle();
expect(find.text('Aggiornamento disponibile'), findsNothing);
expect(calls, hasLength(1));
expect(calls.first.method, 'launch');
expect(calls.first.arguments['url'],
contains('github.com/davide3011/biteplan'));
});
}