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/debug
/android/app/profile /android/app/profile
/android/app/release /android/app/release
upload-keystore.jks
android/key.properties

View file

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

View file

@ -41,5 +41,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>

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"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<color name="ic_launcher_background">#FFFFFF</color> <color name="ic_launcher_background">#2D2D2D</color>
</resources> </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 { class AboutScreen extends StatelessWidget {
const AboutScreen({super.key}); const AboutScreen({super.key});
static const String _helpUrl = 'https://addmonths.app/help'; static const String _howItWorksUrl = 'https://addmonths.com/#how-it-works';
static const String _privacyUrl = 'https://addmonths.app/privacy'; 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 @override
Widget build(BuildContext context) { 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), const SizedBox(height: 32),
// Links // Links
_buildLinkButton( _buildLinkButton(
context, context,
icon: Icons.help_outline, icon: Icons.help_outline,
label: 'How it works', label: 'How It Works',
onTap: () => _launchUrl(_helpUrl), onTap: () => _launchUrl(_howItWorksUrl),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_buildLinkButton( _buildLinkButton(
@ -128,23 +166,19 @@ class AboutScreen extends StatelessWidget {
label: 'Privacy Policy', label: 'Privacy Policy',
onTap: () => _launchUrl(_privacyUrl), onTap: () => _launchUrl(_privacyUrl),
), ),
const SizedBox(height: 32), const SizedBox(height: 12),
_buildLinkButton(
// Disclaimer context,
Text( icon: Icons.description_outlined,
'Disclaimer', label: 'Terms of Service',
style: Theme.of(context).textTheme.titleSmall?.copyWith( onTap: () => _launchUrl(_termsUrl),
color: AppColors.textSecondary,
),
), ),
const SizedBox(height: 8), const SizedBox(height: 12),
Text( _buildLinkButton(
'This app provides general information based on population-level ' context,
'research and is not medical advice. Individual results vary widely. ' icon: Icons.medical_information_outlined,
'Consult a healthcare provider for personalized guidance.', label: 'Medical Disclaimer',
style: Theme.of(context).textTheme.bodySmall?.copyWith( onTap: () => _launchUrl(_disclaimerUrl),
color: AppColors.textSecondary,
),
), ),
], ],
), ),

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 'package:flutter/material.dart';
import '../storage/local_storage.dart';
import '../theme.dart'; import '../theme.dart';
import 'baseline_screen.dart'; import 'baseline_screen.dart';
import 'welcome_screen.dart';
class OnboardingScreen extends StatefulWidget { class OnboardingScreen extends StatefulWidget {
const OnboardingScreen({super.key}); const OnboardingScreen({super.key});
@ -93,16 +95,26 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
if (_currentPage < _slides.length - 1) Row(
TextButton( mainAxisAlignment: MainAxisAlignment.center,
onPressed: _skip, children: [
child: Text( TextButton(
'Skip', onPressed: _skip,
style: TextStyle(color: AppColors.textTertiary), child: Text(
'Skip',
style: TextStyle(color: AppColors.textTertiary),
),
), ),
) const SizedBox(width: 24),
else TextButton(
const SizedBox(height: 40), onPressed: _showSkipForeverConfirmation,
child: Text(
'Skip Forever',
style: TextStyle(color: AppColors.textTertiary),
),
),
],
),
], ],
), ),
), ),
@ -250,6 +262,38 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
MaterialPageRoute(builder: (_) => const BaselineScreen()), 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 { class _SlideData {

View file

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../storage/local_storage.dart';
import '../theme.dart'; import '../theme.dart';
import 'about_screen.dart'; import 'about_screen.dart';
import 'baseline_screen.dart'; import 'baseline_screen.dart';
@ -61,13 +62,23 @@ class WelcomeScreen extends StatelessWidget {
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// About link // Skip Forever and About links
TextButton( Row(
onPressed: () => Navigator.push( mainAxisAlignment: MainAxisAlignment.center,
context, children: [
MaterialPageRoute(builder: (_) => const AboutScreen()), TextButton(
), onPressed: () => _showSkipForeverConfirmation(context),
child: const Text('About'), child: const Text('Skip Forever'),
),
const SizedBox(width: 24),
TextButton(
onPressed: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const AboutScreen()),
),
child: const Text('About'),
),
],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
], ],
@ -82,4 +93,38 @@ class WelcomeScreen extends StatelessWidget {
MaterialPageRoute(builder: (_) => const BaselineScreen()), 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) if (json == null) return false; // Default to imperial (US)
return json['value'] as bool; 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 flutter_secure_storage_macos
import sqflite_darwin import sqflite_darwin
import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) 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 ios: true
remove_alpha_ios: true remove_alpha_ios: true
image_path: "assets/icon/app_icon.png" image_path: "assets/icon/app_icon.png"
adaptive_icon_background: "#FFFFFF" adaptive_icon_background: "#2D2D2D"
adaptive_icon_foreground: "assets/icon/app_icon_foreground.png" adaptive_icon_foreground: "assets/icon/app_icon.png"
# For information on the generic Dart part of this file, see the # For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec # following page: https://dart.dev/tools/pub/pubspec