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>
2
.gitignore
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 3 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 989 B After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 23 KiB |
|
|
@ -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>
|
||||||
|
Before Width: | Height: | Size: 4 MiB After Width: | Height: | Size: 681 KiB |
|
Before Width: | Height: | Size: 9.5 KiB After Width: | Height: | Size: 547 KiB |
|
Before Width: | Height: | Size: 456 B After Width: | Height: | Size: 630 B |
|
Before Width: | Height: | Size: 809 B After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 634 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 809 B After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 2 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 977 B After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 2 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 5 KiB |
|
Before Width: | Height: | Size: 3 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 18 KiB |
|
|
@ -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),
|
|
||||||
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.medical_information_outlined,
|
||||||
|
label: 'Medical Disclaimer',
|
||||||
|
onTap: () => _launchUrl(_disclaimerUrl),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -484,4 +484,5 @@ class _BaselineScreenState extends State<BaselineScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -336,4 +336,5 @@ class _BehavioralScreenState extends State<BehavioralScreen> {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -361,4 +361,5 @@ class _LifestyleScreenState extends State<LifestyleScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: _skip,
|
onPressed: _skip,
|
||||||
child: Text(
|
child: Text(
|
||||||
'Skip',
|
'Skip',
|
||||||
style: TextStyle(color: AppColors.textTertiary),
|
style: TextStyle(color: AppColors.textTertiary),
|
||||||
),
|
),
|
||||||
)
|
),
|
||||||
else
|
const SizedBox(width: 24),
|
||||||
const SizedBox(height: 40),
|
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()),
|
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 {
|
||||||
|
|
|
||||||
|
|
@ -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,7 +62,15 @@ class WelcomeScreen extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
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(
|
TextButton(
|
||||||
onPressed: () => Navigator.push(
|
onPressed: () => Navigator.push(
|
||||||
context,
|
context,
|
||||||
|
|
@ -69,6 +78,8 @@ class WelcomeScreen extends StatelessWidget {
|
||||||
),
|
),
|
||||||
child: const Text('About'),
|
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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||