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:
@@ -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/
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'));
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user