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')); + }); +}