Add Skip Forever, app icon, About screen links, release signing

- Add Skip/Skip Forever links to onboarding screens only
- Remove Skip links from questionnaire screens (baseline, behavioral, lifestyle)
- Add app icon (1024x1024) and generate all platform sizes
- Update About screen with correct URLs (privacy, terms, disclaimer, how it works)
- Add "No ads. Ever." section to About screen
- Configure Android release signing with upload keystore
- Add URL intent query for Android 11+ link launching
- Add skipForever preference to LocalStorage

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
John Mizerek 2026-02-21 13:20:58 -08:00
parent 90ffe964b4
commit 247388c61a
45 changed files with 202 additions and 42 deletions

2
.gitignore vendored
View file

@ -43,3 +43,5 @@ app.*.map.json
/android/app/debug
/android/app/profile
/android/app/release
upload-keystore.jks
android/key.properties

View file

@ -1,3 +1,6 @@
import java.util.Properties
import java.io.FileInputStream
plugins {
id("com.android.application")
id("kotlin-android")
@ -5,6 +8,12 @@ plugins {
id("dev.flutter.flutter-gradle-plugin")
}
val keystorePropertiesFile = rootProject.file("key.properties")
val keystoreProperties = Properties()
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
}
android {
namespace = "com.payfrit.add_months"
compileSdk = flutter.compileSdkVersion
@ -30,11 +39,18 @@ android {
versionName = flutter.versionName
}
signingConfigs {
create("release") {
keyAlias = keystoreProperties["keyAlias"] as String?
keyPassword = keystoreProperties["keyPassword"] as String?
storeFile = keystoreProperties["storeFile"]?.let { file(it) }
storePassword = keystoreProperties["storePassword"] as String?
}
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
signingConfig = signingConfigs.getByName("release")
}
}
}

View file

@ -41,5 +41,9 @@
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
<intent>
<action android:name="android.intent.action.VIEW"/>
<data android:scheme="https"/>
</intent>
</queries>
</manifest>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 989 B

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 23 KiB

View file

@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
<color name="ic_launcher_background">#2D2D2D</color>
</resources>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4 MiB

After

Width:  |  Height:  |  Size: 681 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 547 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 456 B

After

Width:  |  Height:  |  Size: 630 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 809 B

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 634 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 809 B

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 977 B

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View file

@ -5,8 +5,10 @@ import '../theme.dart';
class AboutScreen extends StatelessWidget {
const AboutScreen({super.key});
static const String _helpUrl = 'https://addmonths.app/help';
static const String _privacyUrl = 'https://addmonths.app/privacy';
static const String _howItWorksUrl = 'https://addmonths.com/#how-it-works';
static const String _privacyUrl = 'https://addmonths.com/privacy/';
static const String _termsUrl = 'https://addmonths.com/terms/';
static const String _disclaimerUrl = 'https://addmonths.com/disclaimer/';
@override
Widget build(BuildContext context) {
@ -112,14 +114,50 @@ class AboutScreen extends StatelessWidget {
],
),
),
const SizedBox(height: 16),
// No ads promise
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.surfaceVariant,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
const Icon(
Icons.block,
color: AppColors.primary,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'No ads. Ever.',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 4),
Text(
'No trackers, no monetization schemes. '
'Just a simple tool that respects your time.',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
],
),
),
const SizedBox(height: 32),
// Links
_buildLinkButton(
context,
icon: Icons.help_outline,
label: 'How it works',
onTap: () => _launchUrl(_helpUrl),
label: 'How It Works',
onTap: () => _launchUrl(_howItWorksUrl),
),
const SizedBox(height: 12),
_buildLinkButton(
@ -128,23 +166,19 @@ class AboutScreen extends StatelessWidget {
label: 'Privacy Policy',
onTap: () => _launchUrl(_privacyUrl),
),
const SizedBox(height: 32),
// Disclaimer
Text(
'Disclaimer',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: AppColors.textSecondary,
),
),
const SizedBox(height: 8),
Text(
'This app provides general information based on population-level '
'research and is not medical advice. Individual results vary widely. '
'Consult a healthcare provider for personalized guidance.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: AppColors.textSecondary,
const SizedBox(height: 12),
_buildLinkButton(
context,
icon: Icons.description_outlined,
label: 'Terms of Service',
onTap: () => _launchUrl(_termsUrl),
),
const SizedBox(height: 12),
_buildLinkButton(
context,
icon: Icons.medical_information_outlined,
label: 'Medical Disclaimer',
onTap: () => _launchUrl(_disclaimerUrl),
),
],
),

View file

@ -484,4 +484,5 @@ class _BaselineScreenState extends State<BaselineScreen> {
);
}
}
}

View file

@ -336,4 +336,5 @@ class _BehavioralScreenState extends State<BehavioralScreen> {
),
);
}
}

View file

@ -361,4 +361,5 @@ class _LifestyleScreenState extends State<LifestyleScreen> {
);
}
}
}

View file

@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import '../storage/local_storage.dart';
import '../theme.dart';
import 'baseline_screen.dart';
import 'welcome_screen.dart';
class OnboardingScreen extends StatefulWidget {
const OnboardingScreen({super.key});
@ -93,16 +95,26 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
),
),
const SizedBox(height: 12),
if (_currentPage < _slides.length - 1)
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextButton(
onPressed: _skip,
child: Text(
'Skip',
style: TextStyle(color: AppColors.textTertiary),
),
)
else
const SizedBox(height: 40),
),
const SizedBox(width: 24),
TextButton(
onPressed: _showSkipForeverConfirmation,
child: Text(
'Skip Forever',
style: TextStyle(color: AppColors.textTertiary),
),
),
],
),
],
),
),
@ -250,6 +262,38 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
MaterialPageRoute(builder: (_) => const BaselineScreen()),
);
}
void _showSkipForeverConfirmation() {
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Skip Forever?'),
content: const Text(
'You won\'t be asked to complete the questionnaire again. '
'You can still access the app from the About screen.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('Cancel'),
),
TextButton(
onPressed: () async {
await LocalStorage.setSkipForever(true);
Navigator.pop(dialogContext);
if (mounted) {
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (_) => const WelcomeScreen()),
(route) => false,
);
}
},
child: const Text('Skip Forever'),
),
],
),
);
}
}
class _SlideData {

View file

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import '../storage/local_storage.dart';
import '../theme.dart';
import 'about_screen.dart';
import 'baseline_screen.dart';
@ -61,7 +62,15 @@ class WelcomeScreen extends StatelessWidget {
),
),
const SizedBox(height: 16),
// About link
// Skip Forever and About links
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextButton(
onPressed: () => _showSkipForeverConfirmation(context),
child: const Text('Skip Forever'),
),
const SizedBox(width: 24),
TextButton(
onPressed: () => Navigator.push(
context,
@ -69,6 +78,8 @@ class WelcomeScreen extends StatelessWidget {
),
child: const Text('About'),
),
],
),
const SizedBox(height: 16),
],
),
@ -82,4 +93,38 @@ class WelcomeScreen extends StatelessWidget {
MaterialPageRoute(builder: (_) => const BaselineScreen()),
);
}
void _showSkipForeverConfirmation(BuildContext context) {
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Skip Forever?'),
content: const Text(
'You won\'t be asked to complete the questionnaire again. '
'You can still access the app from the About screen.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('Cancel'),
),
TextButton(
onPressed: () async {
await LocalStorage.setSkipForever(true);
Navigator.pop(dialogContext);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Questionnaire skipped'),
duration: Duration(seconds: 2),
),
);
}
},
child: const Text('Skip Forever'),
),
],
),
);
}
}

View file

@ -270,4 +270,14 @@ class LocalStorage {
if (json == null) return false; // Default to imperial (US)
return json['value'] as bool;
}
static Future<void> setSkipForever(bool skip) async {
await _put('skipForever', {'value': skip});
}
static Future<bool> getSkipForever() async {
final json = await _get('skipForever');
if (json == null) return false;
return json['value'] as bool;
}
}

View file

@ -7,8 +7,10 @@ import Foundation
import flutter_secure_storage_macos
import sqflite_darwin
import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
}

View file

@ -48,8 +48,8 @@ flutter_launcher_icons:
ios: true
remove_alpha_ios: true
image_path: "assets/icon/app_icon.png"
adaptive_icon_background: "#FFFFFF"
adaptive_icon_foreground: "assets/icon/app_icon_foreground.png"
adaptive_icon_background: "#2D2D2D"
adaptive_icon_foreground: "assets/icon/app_icon.png"
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec