Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,127 changes: 1,127 additions & 0 deletions .claude/CLAUDE.md

Large diffs are not rendered by default.

7 changes: 1 addition & 6 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,7 @@ migrate_working_dir/
*.ipr
*.iws
.idea/

# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/

.vscode
# Flutter/Dart/Pub related
**/ios/Flutter/.last_build_id
.dart_tool/
Expand Down
2 changes: 1 addition & 1 deletion WIP.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ Groups are collections of people (like Telegram/WhatsApp groups):
### Phase 1: Foundation

#### Authentication
- [ ] Email/password login
- [X] Email/password login
- [ ] Google Sign-In
- [ ] Apple Sign-In (iOS)
- [ ] Password reset flow
Expand Down
16 changes: 16 additions & 0 deletions assets/config/dev.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"env": "development",
"enable_logging": true,
"supabase": {
"url": "http://127.0.0.1:54321",
"url_android": "http://10.0.2.2:54321",
"anon_key": "sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH"
},
"storage": {
"url": "http://127.0.0.1:54321/storage/v1/s3",
"url_android": "http://10.0.2.2:54321/storage/v1/s3",
"access_key": "625729a08b95bf1b7ff351a663f3a23c",
"secret_key": "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907",
"region": "local"
}
}
14 changes: 14 additions & 0 deletions assets/config/prod.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"env": "production",
"enable_logging": false,
"supabase": {
"url": "https://hohxbxrqglxfsgeatdvd.supabase.co",
"anon_key": "sb_publishable_y4ZHAHbVLoK26qupHesTZQ_3fAG1_3x"
},
"storage": {
"url": "https://hohxbxrqglxfsgeatdvd.supabase.co/storage/v1/s3",
"access_key": "",
"secret_key": "",
"region": ""
}
}
114 changes: 114 additions & 0 deletions lib/config/app_config.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import 'dart:convert';
import 'dart:io' show Platform;
import 'package:flutter/services.dart';

class SupabaseConfig {
final String url;
final String anonKey;

const SupabaseConfig({
required this.url,
required this.anonKey,
});

factory SupabaseConfig.fromJson(Map<String, dynamic> json) {
return SupabaseConfig(
url: json['url'] as String,
anonKey: json['anon_key'] as String,
);
}
}

class StorageConfig {
final String url;
final String accessKey;
final String secretKey;
final String region;

const StorageConfig({
required this.url,
required this.accessKey,
required this.secretKey,
required this.region,
});

factory StorageConfig.fromJson(Map<String, dynamic> json) {
return StorageConfig(
url: json['url'] as String,
accessKey: json['access_key'] as String,
secretKey: json['secret_key'] as String,
region: json['region'] as String,
);
}
}

class AppConfig {
final String env;
final bool enableLogging;
final SupabaseConfig supabase;
final StorageConfig storage;

const AppConfig({
required this.env,
required this.enableLogging,
required this.supabase,
required this.storage,
});

/// Global instance - set during app initialization
static late AppConfig instance;

/// Get environment from --dart-define=ENV=dev (defaults to 'dev')
static const String environment =
String.fromEnvironment('ENV', defaultValue: 'dev');

/// Whether we're in development mode
bool get isDevelopment => env == 'development';

/// Whether we're in production mode
bool get isProduction => env == 'production';

/// Get platform-specific URL from config.
/// Uses url_android if on Android and available, otherwise falls back to url.
static String _getUrl(Map<String, dynamic> json) {
final androidUrl = json['url_android'] as String?;
final defaultUrl = json['url'] as String;
return (Platform.isAndroid && androidUrl != null) ? androidUrl : defaultUrl;
}

/// Load configuration from JSON asset file based on ENV
static Future<AppConfig> load() async {
final jsonString = await rootBundle.loadString(
'assets/config/$environment.json',
);
final json = jsonDecode(jsonString) as Map<String, dynamic>;

final supabaseJson = json['supabase'] as Map<String, dynamic>;
final storageJson = json['storage'] as Map<String, dynamic>;

final config = AppConfig(
env: json['env'] as String,
enableLogging: json['enable_logging'] as bool? ?? false,
supabase: SupabaseConfig(
url: _getUrl(supabaseJson),
anonKey: supabaseJson['anon_key'] as String,
),
storage: StorageConfig(
url: _getUrl(storageJson),
accessKey: storageJson['access_key'] as String,
secretKey: storageJson['secret_key'] as String,
region: storageJson['region'] as String,
),
);

instance = config;
return config;
}

/// Log only in development
void log(String message) {
if (enableLogging) {
print('[$env] $message');
}
}
}
34 changes: 32 additions & 2 deletions lib/controller/auth_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,41 @@ class AuthService {
return user?.email;
}

Future<void> deleteAccount() async {
/// Disables account by setting disabled_at timestamp.
/// This preserves all user data including purchases.
Future<void> disableAccount() async {
final user = _supabase.auth.currentUser;
if (user != null) {
await _supabase.from('users').delete().eq('id', user.id);
await _supabase.from('profiles').update({
'disabled_at': DateTime.now().toUtc().toIso8601String(),
}).eq('id', user.id);
await _supabase.auth.signOut();
}
}

/// Checks if the current user's account is disabled.
/// Returns the disabled_at timestamp if disabled, null if active.
Future<DateTime?> checkAccountDisabled() async {
final user = _supabase.auth.currentUser;
if (user == null) return null;

final response = await _supabase
.from('profiles')
.select('disabled_at')
.eq('id', user.id)
.single();

final disabledAt = response['disabled_at'];
return disabledAt != null ? DateTime.parse(disabledAt) : null;
}

/// Re-enables account by clearing disabled_at.
Future<void> enableAccount() async {
final user = _supabase.auth.currentUser;
if (user != null) {
await _supabase.from('profiles').update({
'disabled_at': null,
}).eq('id', user.id);
}
}
}
20 changes: 19 additions & 1 deletion lib/controller/pages/login_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import 'package:myapp/controller/navigation.dart';
import 'package:supabase_flutter/supabase_flutter.dart';

import '../../view/auth/login_scaffold.dart';
import '../../view/auth/reenable_account_dialog.dart';
import '../auth_service.dart';

class LoginPage extends StatefulWidget {
Expand All @@ -25,7 +26,24 @@ class _LoginPageState extends State<LoginPage> {

try {
await authService.signInWithEmailPassword(email, password);
if (mounted) _navigationService.navigateToMain(context);

if (!mounted) return;
final disabledAt = await authService.checkAccountDisabled();

if (!mounted) return;
if (disabledAt != null) {
final shouldReenable = await showReenableAccountDialog(context);

if (!mounted) return;
if (shouldReenable) {
await authService.enableAccount();
if (mounted) _navigationService.navigateToMain(context);
} else {
await authService.signOut();
}
} else {
_navigationService.navigateToMain(context);
}
} catch (error) {
if (mounted) {
if (error is AuthException &&
Expand Down
32 changes: 28 additions & 4 deletions lib/controller/pages/profile_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,33 @@ class _ProfilePageState extends State<ProfilePage> {
if (mounted) _navigationService.navigateToLogin(context);
}

void deleteAccount() async {
await Provider.of<AuthService>(context, listen: false).deleteAccount();
if (mounted) _navigationService.navigateToLogin(context);
void disableAccount() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Disable Account'),
content: const Text(
'Your account will be disabled and you will be signed out. '
'You can re-enable it by logging in again. '
'Your data and purchases will be preserved.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Disable'),
),
],
),
);

if (confirmed == true && mounted) {
await Provider.of<AuthService>(context, listen: false).disableAccount();
if (mounted) _navigationService.navigateToLogin(context);
}
}

@override
Expand All @@ -32,7 +56,7 @@ class _ProfilePageState extends State<ProfilePage> {
return ProfileScaffold(
email: email,
onLogout: logout,
onDeleteAccount: deleteAccount,
onDisableAccount: disableAccount,
);
}
}
20 changes: 15 additions & 5 deletions lib/main.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:myapp/config/app_config.dart';
import 'package:myapp/controller/auth_service.dart';
import 'package:myapp/controller/pages/login_page.dart';
import 'package:provider/provider.dart';
Expand All @@ -10,12 +11,19 @@ import 'view/menu.dart';

void main() async {
WidgetsFlutterBinding.ensureInitialized();

// Load configuration from JSON (ENV comes from --dart-define)
final config = await AppConfig.load();
config.log('Starting app with ${config.env} configuration');
config.log('Supabase URL: ${config.supabase.url}');

// Initialize Supabase with config values
await Supabase.initialize(
url: 'https://jgywuqgtfzblbaqprvdb.supabase.co',
anonKey:
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImpneXd1cWd0ZnpibGJhcXBydmRiIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MzYwMTg3NjQsImV4cCI6MjA1MTU5NDc2NH0.A2lzmtfewM5Rm9LpZlWq4u9fjcPHwmjYNk-y5wD_dBo',
url: config.supabase.url,
anonKey: config.supabase.anonKey,
);
runApp(MyApp());

runApp(const MyApp());
}

class MyApp extends StatelessWidget {
Expand Down Expand Up @@ -47,7 +55,9 @@ class MyApp extends StatelessWidget {
future: _isLoggedIn(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const CircularProgressIndicator();
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
} else if (snapshot.hasData && snapshot.data == true) {
return Menu();
} else {
Expand Down
10 changes: 5 additions & 5 deletions lib/view/auth/profile_scaffold.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ import 'package:flutter/material.dart';
class ProfileScaffold extends StatelessWidget {
final String? email;
final VoidCallback onLogout;
final VoidCallback onDeleteAccount;
final VoidCallback onDisableAccount;

const ProfileScaffold({
super.key,
required this.email,
required this.onLogout,
required this.onDeleteAccount,
required this.onDisableAccount,
});

@override
Expand All @@ -27,11 +27,11 @@ class ProfileScaffold extends StatelessWidget {
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: onDeleteAccount,
onPressed: onDisableAccount,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red, // Red color for delete button
backgroundColor: Colors.orange,
),
child: const Text('Delete Account'),
child: const Text('Disable Account'),
),
],
),
Expand Down
27 changes: 27 additions & 0 deletions lib/view/auth/reenable_account_dialog.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';

/// Shows a dialog asking if the user wants to re-enable their disabled account.
/// Returns true if user wants to re-enable, false if they want to stay signed out.
Future<bool> showReenableAccountDialog(BuildContext context) async {
final result = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
title: const Text('Account Disabled'),
content: const Text(
'Your account is currently disabled. Would you like to re-enable it?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('No, Sign Out'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Yes, Re-enable'),
),
],
),
);
return result ?? false;
}
Loading