diff --git a/.gitignore b/.gitignore
index d516c35..283dd80 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,6 +10,9 @@ dist/
/build/
*.g.dart
+# Generato da flutter create — non appartiene al progetto
+test/widget_test.dart
+
# Piattaforme non usate
/ios/
/linux/
diff --git a/CLAUDE.md b/CLAUDE.md
index 9d074e4..9625bf8 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -84,24 +84,27 @@ lib/
## Testing
-110 test (unit + widget). Non richiedono device fisico.
+130 test (unit + widget). Non richiedono device fisico.
```
test/
├── helpers/pump_app.dart # estensione pumpApp per widget test
-└── features/
- ├── converter/
- │ ├── models/conversion_entry_test.dart
- │ └── providers/converter_provider_test.dart
- ├── meal_planner/
- │ ├── models/meal_plan_test.dart
- │ ├── providers/meal_planner_provider_test.dart
- │ ├── widgets/meal_card_test.dart
- │ └── qr_test.dart
- └── shopping_list/
- ├── models/shopping_item_test.dart
- ├── providers/shopping_list_provider_test.dart
- └── widgets/shopping_item_tile_test.dart
+├── features/
+│ ├── converter/
+│ │ ├── models/conversion_entry_test.dart
+│ │ └── providers/converter_provider_test.dart
+│ ├── meal_planner/
+│ │ ├── models/meal_plan_test.dart
+│ │ ├── providers/meal_planner_provider_test.dart
+│ │ ├── widgets/meal_card_test.dart
+│ │ └── qr_test.dart
+│ └── shopping_list/
+│ ├── models/shopping_item_test.dart
+│ ├── providers/shopping_list_provider_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.
diff --git a/README.md b/README.md
index ce11d73..0f4bbb2 100644
--- a/README.md
+++ b/README.md
@@ -24,6 +24,10 @@ App Android per la gestione della dieta quotidiana — pianificazione pasti, con
- Separazione visiva tra elementi da completare e completati
- 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
| Livello | Tecnologia |
@@ -74,19 +78,24 @@ docker run --rm -v "$(pwd):/workspace" -w /workspace biteplan-build \
test/
├── helpers/
│ └── pump_app.dart # estensione pumpApp per widget test
-└── features/
- ├── converter/
- │ ├── models/conversion_entry_test.dart
- │ └── providers/converter_provider_test.dart
- ├── meal_planner/
- │ ├── models/meal_plan_test.dart
- │ ├── providers/meal_planner_provider_test.dart
- │ ├── widgets/meal_card_test.dart
- │ └── qr_test.dart
- └── shopping_list/
- ├── models/shopping_item_test.dart
- ├── providers/shopping_list_provider_test.dart
- └── widgets/shopping_item_tile_test.dart
+├── features/
+│ ├── converter/
+│ │ ├── models/conversion_entry_test.dart
+│ │ └── providers/converter_provider_test.dart
+│ ├── meal_planner/
+│ │ ├── models/meal_plan_test.dart
+│ │ ├── providers/meal_planner_provider_test.dart
+│ │ ├── widgets/meal_card_test.dart
+│ │ └── qr_test.dart
+│ └── shopping_list/
+│ ├── models/shopping_item_test.dart
+│ ├── providers/shopping_list_provider_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
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index bbc0ef6..1942b2e 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -1,5 +1,6 @@
+
+
+
+
+
diff --git a/android/app/src/main/kotlin/com/davide/biteplan/MainActivity.kt b/android/app/src/main/kotlin/com/davide/biteplan/MainActivity.kt
index c87d893..d14c341 100644
--- a/android/app/src/main/kotlin/com/davide/biteplan/MainActivity.kt
+++ b/android/app/src/main/kotlin/com/davide/biteplan/MainActivity.kt
@@ -1,5 +1,29 @@
package com.davide.biteplan
+import android.content.Intent
+import android.net.Uri
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("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()
+ }
+ }
+ }
+}
diff --git a/lib/app.dart b/lib/app.dart
index 48844a4..83723fb 100644
--- a/lib/app.dart
+++ b/lib/app.dart
@@ -4,6 +4,8 @@ import 'features/meal_planner/presentation/pages/meal_planner_page.dart';
import 'features/converter/presentation/pages/converter_page.dart';
import 'features/shopping_list/presentation/pages/shopping_list_page.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 {
const BitePlanApp({super.key});
@@ -42,6 +44,11 @@ class _MainScaffoldState extends State<_MainScaffold> {
const ConverterPage(),
const ShoppingListPage(),
];
+ UpdateService.checkUpdate().then((newVersion) {
+ if (newVersion != null && mounted) {
+ showUpdateDialog(context, newVersion);
+ }
+ });
}
@override
diff --git a/lib/shared/services/update_service.dart b/lib/shared/services/update_service.dart
new file mode 100644
index 0000000..4035ded
--- /dev/null
+++ b/lib/shared/services/update_service.dart
@@ -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 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;
+ 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 parseVersion(String v) {
+ final parts = v.split('.');
+ return List.generate(
+ 3, (i) => i < parts.length ? (int.tryParse(parts[i]) ?? 0) : 0);
+ }
+}
diff --git a/lib/shared/services/url_launcher_service.dart b/lib/shared/services/url_launcher_service.dart
new file mode 100644
index 0000000..d78d6b1
--- /dev/null
+++ b/lib/shared/services/url_launcher_service.dart
@@ -0,0 +1,9 @@
+import 'package:flutter/services.dart';
+
+class UrlLauncherService {
+ static const _channel = MethodChannel('com.davide.biteplan/launcher');
+
+ static Future launch(String url) async {
+ await _channel.invokeMethod('launch', {'url': url});
+ }
+}
diff --git a/lib/shared/widgets/update_dialog.dart b/lib/shared/widgets/update_dialog.dart
new file mode 100644
index 0000000..eab8432
--- /dev/null
+++ b/lib/shared/widgets/update_dialog.dart
@@ -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(
+ 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'),
+ ),
+ ],
+ ),
+ );
+}
diff --git a/test/shared/services/update_service_test.dart b/test/shared/services/update_service_test.dart
new file mode 100644
index 0000000..462e03e
--- /dev/null
+++ b/test/shared/services/update_service_test.dart
@@ -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);
+ });
+ });
+}
diff --git a/test/shared/widgets/update_dialog_test.dart b/test/shared/widgets/update_dialog_test.dart
new file mode 100644
index 0000000..777300c
--- /dev/null
+++ b/test/shared/widgets/update_dialog_test.dart
@@ -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 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 = [];
+ 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'));
+ });
+}