diff --git a/.firebaserc b/.firebaserc new file mode 100644 index 0000000..996aa9f --- /dev/null +++ b/.firebaserc @@ -0,0 +1,5 @@ +{ + "projects": { + "default": "tlistserver" + } +} diff --git a/.github/workflows/firebase-hosting-merge.yml b/.github/workflows/firebase-hosting-merge.yml new file mode 100644 index 0000000..26fc5b9 --- /dev/null +++ b/.github/workflows/firebase-hosting-merge.yml @@ -0,0 +1,20 @@ +# This file was auto-generated by the Firebase CLI +# https://github.com/firebase/firebase-tools + +name: Deploy to Firebase Hosting on merge +on: + push: + branches: + - main +jobs: + build_and_deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: flutter build web + - uses: FirebaseExtended/action-hosting-deploy@v0 + with: + repoToken: ${{ secrets.GITHUB_TOKEN }} + firebaseServiceAccount: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_TLISTSERVER }} + channelId: live + projectId: tlistserver diff --git a/.github/workflows/firebase-hosting-pull-request.yml b/.github/workflows/firebase-hosting-pull-request.yml new file mode 100644 index 0000000..003088f --- /dev/null +++ b/.github/workflows/firebase-hosting-pull-request.yml @@ -0,0 +1,21 @@ +# This file was auto-generated by the Firebase CLI +# https://github.com/firebase/firebase-tools + +name: Deploy to Firebase Hosting on PR +on: pull_request +permissions: + checks: write + contents: read + pull-requests: write +jobs: + build_and_preview: + if: ${{ github.event.pull_request.head.repo.full_name == github.repository }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: flutter build web + - uses: FirebaseExtended/action-hosting-deploy@v0 + with: + repoToken: ${{ secrets.GITHUB_TOKEN }} + firebaseServiceAccount: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_TLISTSERVER }} + projectId: tlistserver diff --git a/.gitignore b/.gitignore index 918e6cc..d0bf2e1 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,13 @@ app.*.map.json /android/app/profile /android/app/release /android/build/reports + +# Firebase +.firebaserc +firebase.json +firebase-debug.log +.firebase +.github +firebaseconfig.js +lib/firebase_options.dart +web/update.json \ No newline at end of file diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index aa26736..8a482bf 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,5 +1,8 @@ plugins { id("com.android.application") + // START: FlutterFire Configuration + id("com.google.gms.google-services") + // END: FlutterFire Configuration id("kotlin-android") // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. id("dev.flutter.flutter-gradle-plugin") diff --git a/android/app/google-services.json b/android/app/google-services.json new file mode 100644 index 0000000..0d3d68d --- /dev/null +++ b/android/app/google-services.json @@ -0,0 +1,48 @@ +{ + "project_info": { + "project_number": "582262425557", + "firebase_url": "https://tlistserver-default-rtdb.asia-southeast1.firebasedatabase.app", + "project_id": "tlistserver", + "storage_bucket": "tlistserver.firebasestorage.app" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:582262425557:android:81685a50fe61a2ad029f5c", + "android_client_info": { + "package_name": "com.friyn.tlist" + } + }, + "oauth_client": [ + { + "client_id": "582262425557-8dtdmlh6an70mqmcocl1quup2g1ds80s.apps.googleusercontent.com", + "client_type": 1, + "android_info": { + "package_name": "com.friyn.tlist", + "certificate_hash": "d13679b2f83cde9bf1da3a91df669e3b63e30de5" + } + }, + { + "client_id": "582262425557-doq1ic0ia81krqmelq24lkb6sh4oaef2.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyAWYT98BF7VQr3y_qThu5wae0vG308nF6k" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "582262425557-doq1ic0ia81krqmelq24lkb6sh4oaef2.apps.googleusercontent.com", + "client_type": 3 + } + ] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 6a67716..3a83bfa 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,18 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..790bcaa --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + + TList + Quick add tasks and notes from home screen + diff --git a/android/app/src/main/res/xml/finance_widget_provider.xml b/android/app/src/main/res/xml/finance_widget_provider.xml new file mode 100644 index 0000000..8de5d2d --- /dev/null +++ b/android/app/src/main/res/xml/finance_widget_provider.xml @@ -0,0 +1,8 @@ + + diff --git a/android/app/src/main/res/xml/quick_add_widget_provider.xml b/android/app/src/main/res/xml/quick_add_widget_provider.xml new file mode 100644 index 0000000..90791ce --- /dev/null +++ b/android/app/src/main/res/xml/quick_add_widget_provider.xml @@ -0,0 +1,10 @@ + + diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index ab39a10..bd7522f 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -19,6 +19,9 @@ pluginManagement { plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("com.android.application") version "8.7.3" apply false + // START: FlutterFire Configuration + id("com.google.gms.google-services") version("4.3.15") apply false + // END: FlutterFire Configuration id("org.jetbrains.kotlin.android") version "2.1.0" apply false } diff --git a/assets/google.png b/assets/google.png new file mode 100644 index 0000000..234c8a3 Binary files /dev/null and b/assets/google.png differ diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/firebase b/firebase new file mode 100644 index 0000000..e69de29 diff --git a/lib/firebase_options.dart b/lib/firebase_options.dart new file mode 100644 index 0000000..3dfa001 --- /dev/null +++ b/lib/firebase_options.dart @@ -0,0 +1,78 @@ +// File generated by FlutterFire CLI. +// ignore_for_file: type=lint +import 'package:firebase_core/firebase_core.dart' show FirebaseOptions; +import 'package:flutter/foundation.dart' + show defaultTargetPlatform, kIsWeb, TargetPlatform; + +/// Default [FirebaseOptions] for use with your Firebase apps. +/// +/// Example: +/// ```dart +/// import 'firebase_options.dart'; +/// // ... +/// await Firebase.initializeApp( +/// options: DefaultFirebaseOptions.currentPlatform, +/// ); +/// ``` +class DefaultFirebaseOptions { + static FirebaseOptions get currentPlatform { + if (kIsWeb) { + return web; + } + switch (defaultTargetPlatform) { + case TargetPlatform.android: + return android; + case TargetPlatform.iOS: + throw UnsupportedError( + 'DefaultFirebaseOptions have not been configured for ios - ' + 'you can reconfigure this by running the FlutterFire CLI again.', + ); + case TargetPlatform.macOS: + throw UnsupportedError( + 'DefaultFirebaseOptions have not been configured for macos - ' + 'you can reconfigure this by running the FlutterFire CLI again.', + ); + case TargetPlatform.windows: + return windows; + case TargetPlatform.linux: + throw UnsupportedError( + 'DefaultFirebaseOptions have not been configured for linux - ' + 'you can reconfigure this by running the FlutterFire CLI again.', + ); + default: + throw UnsupportedError( + 'DefaultFirebaseOptions are not supported for this platform.', + ); + } + } + + static const FirebaseOptions web = FirebaseOptions( + apiKey: 'AIzaSyCE6Rw02MDEKHIe-aqYEoGuk9GYkj2eHG0', + appId: '1:582262425557:web:bb5480d783f97b58029f5c', + messagingSenderId: '582262425557', + projectId: 'tlistserver', + // authDomain: 'tlistserver.firebaseapp.com', + authDomain: 'list.novila.xyz', + storageBucket: 'tlistserver.firebasestorage.app', + measurementId: 'G-GL9ZWVCBD5', + ); + + static const FirebaseOptions android = FirebaseOptions( + apiKey: 'AIzaSyAWYT98BF7VQr3y_qThu5wae0vG308nF6k', + appId: '1:582262425557:android:81685a50fe61a2ad029f5c', + messagingSenderId: '582262425557', + projectId: 'tlistserver', + storageBucket: 'tlistserver.firebasestorage.app', + ); + + static const FirebaseOptions windows = FirebaseOptions( + apiKey: 'AIzaSyCE6Rw02MDEKHIe-aqYEoGuk9GYkj2eHG0', + appId: '1:582262425557:web:a2fb9a11a3eb69e6029f5c', + messagingSenderId: '582262425557', + projectId: 'tlistserver', + // authDomain: 'tlistserver.firebaseapp.com', + authDomain: 'list.novila.xyz', + storageBucket: 'tlistserver.firebasestorage.app', + measurementId: 'G-K1HVP6VG7G', + ); +} diff --git a/lib/main.dart b/lib/main.dart index ae5bcab..8d6cbb7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,9 +1,84 @@ // main.dart +import 'dart:async'; +import 'dart:convert'; + +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:firebase_core/firebase_core.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:home_widget/home_widget.dart'; +import 'package:intl/intl.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'dart:convert'; -void main() { +import 'firebase_options.dart'; +import 'page/user.dart'; +import 'utils/app_update.dart'; + +String _formatCurrency(double amount) { + final format = NumberFormat.currency(locale: 'id_ID', symbol: 'Rp ', decimalDigits: 0); + return format.format(amount); +} + +// Custom formatter untuk input angka dengan pemisah ribuan +class ThousandsSeparatorInputFormatter extends TextInputFormatter { + static const _separator = '.'; + + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + // Hapus semua karakter non-digit + String digitsOnly = newValue.text.replaceAll(RegExp(r'[^\d]'), ''); + + if (digitsOnly.isEmpty) { + return const TextEditingValue(); + } + + // Format dengan pemisah ribuan + String formatted = _addThousandsSeparator(digitsOnly); + + return TextEditingValue( + text: formatted, + selection: TextSelection.collapsed(offset: formatted.length), + ); + } + + String _addThousandsSeparator(String value) { + if (value.length <= 3) return value; + + String result = ''; + int counter = 0; + + for (int i = value.length - 1; i >= 0; i--) { + if (counter == 3) { + result = _separator + result; + counter = 0; + } + result = value[i] + result; + counter++; + } + + return result; + } +} + +// Helper function untuk convert formatted text ke double +double _parseFormattedAmount(String formattedText) { + String digitsOnly = formattedText.replaceAll(RegExp(r'[^\d]'), ''); + return digitsOnly.isEmpty ? 0.0 : double.parse(digitsOnly); +} + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + await Firebase.initializeApp( + options: DefaultFirebaseOptions.currentPlatform, + ); + + // Initialize home widget + await HomeWidget.setAppGroupId('group.com.friyn.tlist'); + runApp(const MyApp()); } @@ -16,7 +91,48 @@ class MyApp extends StatelessWidget { debugShowCheckedModeBanner: false, title: 'TList', theme: ThemeData( - primarySwatch: Colors.blue, + brightness: Brightness.light, + primaryColor: const Color(0xFF128C7E), + colorScheme: ColorScheme.fromSeed( + seedColor: const Color(0xFF128C7E), + brightness: Brightness.light, + ), + scaffoldBackgroundColor: Colors.white, + appBarTheme: const AppBarTheme( + backgroundColor: Colors.white, + foregroundColor: Color(0xFF128C7E), + elevation: 1, + ), + cardTheme: CardThemeData( + elevation: 1, + color: Colors.grey[50], + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + bottomNavigationBarTheme: BottomNavigationBarThemeData( + backgroundColor: Colors.white, + selectedItemColor: const Color(0xFF128C7E), + unselectedItemColor: Colors.grey[600], + elevation: 2, + ), + floatingActionButtonTheme: const FloatingActionButtonThemeData( + backgroundColor: Color(0xFF128C7E), + foregroundColor: Colors.white, + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: Colors.grey.shade100, + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Color(0xFF128C7E), width: 2), + ), + ), visualDensity: VisualDensity.adaptivePlatformDensity, ), home: const MainScreen(), @@ -117,7 +233,7 @@ class Note { this.category = 'Personal', required this.createdAt, DateTime? updatedAt, - this.color = Colors.yellow, + this.color = const Color(0xFFFFF59D), // Warna default: light yellow }) : updatedAt = updatedAt ?? createdAt; Map toJson() { @@ -139,8 +255,10 @@ class Note { content: json['content'], category: json['category'] ?? 'Personal', createdAt: DateTime.fromMillisecondsSinceEpoch(json['createdAt']), - updatedAt: DateTime.fromMillisecondsSinceEpoch(json['updatedAt']), - color: Color(json['color'] ?? Colors.yellow.value), + updatedAt: json['updatedAt'] != null + ? DateTime.fromMillisecondsSinceEpoch(json['updatedAt']) + : DateTime.fromMillisecondsSinceEpoch(json['createdAt']), + color: json['color'] != null ? Color(json['color']) : const Color(0xFFFFF59D), // Default to light yellow ); } } @@ -202,50 +320,132 @@ class DataService { // Tasks (tetap sama) static Future> loadTasks() async { - final prefs = await SharedPreferences.getInstance(); - final String? tasksJson = prefs.getString(_tasksKey); - if (tasksJson == null) return []; - - final List tasksList = json.decode(tasksJson); - return tasksList.map((task) => Task.fromJson(task)).toList(); + final user = FirebaseAuth.instance.currentUser; + if (user != null) { + final snap = await FirebaseFirestore.instance + .collection('users') + .doc(user.uid) + .collection('tasks') + .get(); + return snap.docs.map((d) => Task.fromJson(d.data())).toList(); + } else { + final prefs = await SharedPreferences.getInstance(); + final String? tasksJson = prefs.getString(_tasksKey); + if (tasksJson == null) return []; + final List tasksList = json.decode(tasksJson); + return tasksList.map((task) => Task.fromJson(task)).toList(); + } } static Future saveTasks(List tasks) async { - final prefs = await SharedPreferences.getInstance(); - final String tasksJson = json.encode(tasks.map((task) => task.toJson()).toList()); - await prefs.setString(_tasksKey, tasksJson); + final user = FirebaseAuth.instance.currentUser; + if (user != null) { + final col = FirebaseFirestore.instance + .collection('users') + .doc(user.uid) + .collection('tasks'); + final batch = FirebaseFirestore.instance.batch(); + // clear existing by fetching and deleting, then re-add + final existing = await col.get(); + for (final doc in existing.docs) { + batch.delete(doc.reference); + } + for (final t in tasks) { + final ref = col.doc(t.id); + batch.set(ref, t.toJson()); + } + await batch.commit(); + } else { + final prefs = await SharedPreferences.getInstance(); + final String tasksJson = json.encode(tasks.map((task) => task.toJson()).toList()); + await prefs.setString(_tasksKey, tasksJson); + } } // Notes (tetap sama) static Future> loadNotes() async { - final prefs = await SharedPreferences.getInstance(); - final String? notesJson = prefs.getString(_notesKey); - if (notesJson == null) return []; - - final List notesList = json.decode(notesJson); - return notesList.map((note) => Note.fromJson(note)).toList(); + final user = FirebaseAuth.instance.currentUser; + if (user != null) { + final snap = await FirebaseFirestore.instance + .collection('users') + .doc(user.uid) + .collection('notes') + .get(); + return snap.docs.map((d) => Note.fromJson(d.data())).toList(); + } else { + final prefs = await SharedPreferences.getInstance(); + final String? notesJson = prefs.getString(_notesKey); + if (notesJson == null) return []; + final List notesList = json.decode(notesJson); + return notesList.map((note) => Note.fromJson(note)).toList(); + } } static Future saveNotes(List notes) async { - final prefs = await SharedPreferences.getInstance(); - final String notesJson = json.encode(notes.map((note) => note.toJson()).toList()); - await prefs.setString(_notesKey, notesJson); + final user = FirebaseAuth.instance.currentUser; + if (user != null) { + final col = FirebaseFirestore.instance + .collection('users') + .doc(user.uid) + .collection('notes'); + final batch = FirebaseFirestore.instance.batch(); + final existing = await col.get(); + for (final doc in existing.docs) { + batch.delete(doc.reference); + } + for (final n in notes) { + final ref = col.doc(n.id); + batch.set(ref, n.toJson()); + } + await batch.commit(); + } else { + final prefs = await SharedPreferences.getInstance(); + final String notesJson = json.encode(notes.map((note) => note.toJson()).toList()); + await prefs.setString(_notesKey, notesJson); + } } // Transactions (BARU) static Future> loadTransactions() async { - final prefs = await SharedPreferences.getInstance(); - final String? transactionsJson = prefs.getString(_transactionsKey); - if (transactionsJson == null) return []; - - final List transactionsList = json.decode(transactionsJson); - return transactionsList.map((transaction) => Transaction.fromJson(transaction)).toList(); + final user = FirebaseAuth.instance.currentUser; + if (user != null) { + final snap = await FirebaseFirestore.instance + .collection('users') + .doc(user.uid) + .collection('transactions') + .get(); + return snap.docs.map((d) => Transaction.fromJson(d.data())).toList(); + } else { + final prefs = await SharedPreferences.getInstance(); + final String? transactionsJson = prefs.getString(_transactionsKey); + if (transactionsJson == null) return []; + final List transactionsList = json.decode(transactionsJson); + return transactionsList.map((transaction) => Transaction.fromJson(transaction)).toList(); + } } static Future saveTransactions(List transactions) async { - final prefs = await SharedPreferences.getInstance(); - final String transactionsJson = json.encode(transactions.map((transaction) => transaction.toJson()).toList()); - await prefs.setString(_transactionsKey, transactionsJson); + final user = FirebaseAuth.instance.currentUser; + if (user != null) { + final col = FirebaseFirestore.instance + .collection('users') + .doc(user.uid) + .collection('transactions'); + final batch = FirebaseFirestore.instance.batch(); + final existing = await col.get(); + for (final doc in existing.docs) { + batch.delete(doc.reference); + } + for (final tr in transactions) { + final ref = col.doc(tr.id); + batch.set(ref, tr.toJson()); + } + await batch.commit(); + } else { + final prefs = await SharedPreferences.getInstance(); + final String transactionsJson = json.encode(transactions.map((transaction) => transaction.toJson()).toList()); + await prefs.setString(_transactionsKey, transactionsJson); + } } // Categories @@ -281,15 +481,166 @@ class MainScreen extends StatefulWidget { class _MainScreenState extends State { int _currentIndex = 0; final TextEditingController _searchController = TextEditingController(); + late PageController _pageController; String _searchQuery = ''; + bool _loginPromptShown = false; + // Optional: URL ke manifest update (JSON) yang di-host di GitHub Pages/Releases. + // Kosongkan jika tidak ingin mengaktifkan fitur update popup. + static const String _updateManifestUrl = 'https://tlistserver.web.app/update.json'; + + final List _titles = ['Tasks', 'Notes', 'Keuangan']; - final List _titles = ['To-Do List', 'My Notes', 'Keuangan']; + // Auth subscription for auto refresh on login/logout/register + StreamSubscription? _authSub; + bool _pendingAuthRefresh = false; + + // Global keys to control refresh on each tab + final GlobalKey<_TodoListScreenState> _todoKey = GlobalKey<_TodoListScreenState>(); + final GlobalKey<_NotesScreenState> _notesKey = GlobalKey<_NotesScreenState>(); + final GlobalKey<_FinanceScreenState> _financeKey = GlobalKey<_FinanceScreenState>(); + + // Centralized refresh for all tabs + Future _refreshAll() async { + await Future.wait([ + _todoKey.currentState?.refresh() ?? Future.value(), + _notesKey.currentState?.refresh() ?? Future.value(), + _financeKey.currentState?.refresh() ?? Future.value(), + ]); + } + + @override + void initState() { + super.initState(); + _pageController = PageController(initialPage: _currentIndex); + // Listen to auth state changes and trigger global refresh (next frame, debounced) + _authSub = FirebaseAuth.instance.authStateChanges().listen((_) { + if (!mounted || _pendingAuthRefresh) return; + _pendingAuthRefresh = true; + try { + WidgetsBinding.instance.addPostFrameCallback((_) async { + try { + if (mounted) { + await _refreshAll(); + } + } catch (e, st) { + debugPrint('Auth refresh error: $e\n$st'); + } finally { + _pendingAuthRefresh = false; + } + }); + } catch (e, st) { + debugPrint('Scheduling auth refresh failed: $e\n$st'); + _pendingAuthRefresh = false; + } + }); + // Tampilkan popup benefit login setelah frame pertama agar context siap + WidgetsBinding.instance.addPostFrameCallback((_) { + _maybeShowLoginPrompt(); + }); + // Cek pembaruan aplikasi (cross-platform) setelah frame pertama + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_updateManifestUrl.isNotEmpty) { + AppUpdate.checkAndPrompt( + context, + config: const AppUpdateConfig(manifestUrl: _updateManifestUrl), + ); + } + }); + } + + Future _maybeShowLoginPrompt() async { + if (_loginPromptShown) return; + final user = FirebaseAuth.instance.currentUser; + if (user != null) return; // sudah login, tidak perlu prompt + final prefs = await SharedPreferences.getInstance(); + final seen = prefs.getBool('seen_login_benefit_prompt') ?? false; + if (seen) { + _loginPromptShown = true; + return; + } + _loginPromptShown = true; + if (!mounted) return; + bool doNotShowAgain = false; + showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) { + return StatefulBuilder( + builder: (context, setStateDialog) { + return AlertDialog( + title: const Text('Login ke TList'), + content: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Dengan login kamu bisa'), + const SizedBox(height: 8), + const Text('- Sinkronisasi data ke cloud'), + const Text('- Akses data di banyak perangkat'), + const Text('- Cadangan otomatis'), + const Text(''), + const SizedBox(height: 12), + CheckboxListTile( + value: doNotShowAgain, + onChanged: (v) { + setStateDialog(() { + doNotShowAgain = v ?? false; + }); + }, + title: const Text('Jangan tampilkan lagi'), + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () async { + final p = await SharedPreferences.getInstance(); + await p.setBool('seen_login_benefit_prompt', doNotShowAgain); + if (Navigator.of(ctx).canPop()) Navigator.of(ctx).pop(); + }, + child: const Text('Nanti saja'), + ), + ElevatedButton( + onPressed: () async { + final p = await SharedPreferences.getInstance(); + await p.setBool('seen_login_benefit_prompt', doNotShowAgain); + if (Navigator.of(ctx).canPop()) Navigator.of(ctx).pop(); + if (!mounted) return; + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const UserPage()), + ); + }, + child: const Text('Ya, Login'), + ), + ], + ); + }, + ); + }, + ); + } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(_titles[_currentIndex]), + actions: [ + const SizedBox(height: 16), + IconButton( + icon: const Icon(Icons.person), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const UserPage()), + ); + }, + ), + ], bottom: PreferredSize( preferredSize: const Size.fromHeight(60), child: Padding( @@ -315,7 +666,6 @@ class _MainScreenState extends State { borderRadius: BorderRadius.circular(25), ), filled: true, - fillColor: Colors.white, contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), ), onChanged: (value) { @@ -327,31 +677,36 @@ class _MainScreenState extends State { ), ), ), - body: IndexedStack( - index: _currentIndex, + body: PageView( + controller: _pageController, + onPageChanged: (index) { + setState(() { + _currentIndex = index; + _searchController.clear(); + _searchQuery = ''; + }); + }, children: [ - TodoListScreen(searchQuery: _searchQuery), - NotesScreen(searchQuery: _searchQuery), - FinanceScreen(searchQuery: _searchQuery), // Screen baru + TodoListScreen(key: _todoKey, searchQuery: _searchQuery, onGlobalRefresh: _refreshAll), + NotesScreen(key: _notesKey, searchQuery: _searchQuery, onGlobalRefresh: _refreshAll), + FinanceScreen(key: _financeKey, searchQuery: _searchQuery, onGlobalRefresh: _refreshAll), ], ), bottomNavigationBar: BottomNavigationBar( currentIndex: _currentIndex, type: BottomNavigationBarType.fixed, - selectedItemColor: Colors.blue, - unselectedItemColor: Colors.grey, onTap: (index) { - setState(() { - _currentIndex = index; - _searchController.clear(); - _searchQuery = ''; - }); + _pageController.animateToPage( + index, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); }, items: const [ BottomNavigationBarItem( icon: Icon(Icons.check_circle_outline), activeIcon: Icon(Icons.check_circle), - label: 'To-Do', + label: 'Tasks', ), BottomNavigationBarItem( icon: Icon(Icons.note_outlined), @@ -370,7 +725,9 @@ class _MainScreenState extends State { @override void dispose() { + _authSub?.cancel(); _searchController.dispose(); + _pageController.dispose(); super.dispose(); } } @@ -378,8 +735,9 @@ class _MainScreenState extends State { // Finance Screen (BARU) class FinanceScreen extends StatefulWidget { final String searchQuery; + final Future Function()? onGlobalRefresh; - const FinanceScreen({Key? key, required this.searchQuery}) : super(key: key); + const FinanceScreen({Key? key, required this.searchQuery, this.onGlobalRefresh}) : super(key: key); @override State createState() => _FinanceScreenState(); @@ -390,6 +748,31 @@ class _FinanceScreenState extends State { List incomeCategories = []; List expenseCategories = []; String selectedFilter = 'Semua'; // 'Semua', 'Pemasukan', 'Pengeluaran' + bool isLoading = true; + + double get _balance { + double income = 0; + double expense = 0; + for (final t in transactions) { + if (t.type == 'income') { + income += t.amount; + } else if (t.type == 'expense') { + expense += t.amount; + } + } + return income - expense; + } + + Future _updateAndroidFinanceWidget() async { + try { + final balanceText = _formatCurrency(_balance); + await HomeWidget.saveWidgetData('balance', balanceText); + await HomeWidget.updateWidget(name: 'FinanceWidgetProvider'); + } catch (_) { + // ignore widget update errors gracefully + } + } + @override void initState() { @@ -398,6 +781,10 @@ class _FinanceScreenState extends State { } Future _loadData() async { + setState(() { + isLoading = true; + }); + final loadedTransactions = await DataService.loadTransactions(); final loadedIncomeCategories = await DataService.loadIncomeCategories(); final loadedExpenseCategories = await DataService.loadExpenseCategories(); @@ -406,11 +793,17 @@ class _FinanceScreenState extends State { transactions = loadedTransactions; incomeCategories = loadedIncomeCategories; expenseCategories = loadedExpenseCategories; + isLoading = false; }); + await _updateAndroidFinanceWidget(); } + // Expose public refresh for global trigger + Future refresh() => _loadData(); + Future _saveTransactions() async { await DataService.saveTransactions(transactions); + await _updateAndroidFinanceWidget(); } List get filteredTransactions { @@ -509,7 +902,7 @@ class _FinanceScreenState extends State { _saveTransactions(); Navigator.pop(context); }, - child: const Text('Hapus', style: TextStyle(color: Colors.red)), + child: Text('Hapus', style: TextStyle(color: Theme.of(context).colorScheme.error)), ), ], ), @@ -518,6 +911,21 @@ class _FinanceScreenState extends State { @override Widget build(BuildContext context) { + if (isLoading) { + return const Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Memuat data keuangan...'), + ], + ), + ), + ); + } + return Scaffold( body: Column( children: [ @@ -529,8 +937,9 @@ class _FinanceScreenState extends State { Expanded( child: _buildSummaryCard( 'Pemasukan', - totalIncome, - Colors.green, + _formatCurrency(totalIncome), + Theme.of(context).colorScheme.primary.withOpacity(0.1), + Theme.of(context).colorScheme.primary, Icons.trending_up, ), ), @@ -538,8 +947,9 @@ class _FinanceScreenState extends State { Expanded( child: _buildSummaryCard( 'Saldo', - balance, - balance >= 0 ? Colors.green : Colors.red, + _formatCurrency(balance), + (balance >= 0 ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.error).withOpacity(0.1), + balance >= 0 ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.error, Icons.account_balance_wallet, ), ), @@ -547,8 +957,9 @@ class _FinanceScreenState extends State { Expanded( child: _buildSummaryCard( 'Pengeluaran', - totalExpense, - Colors.red, + _formatCurrency(totalExpense), + Theme.of(context).colorScheme.error.withOpacity(0.1), + Theme.of(context).colorScheme.error, Icons.trending_down, ), ), @@ -598,50 +1009,62 @@ class _FinanceScreenState extends State { // Transactions List Expanded( - child: filteredTransactions.isEmpty - ? Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, + child: RefreshIndicator( + onRefresh: widget.onGlobalRefresh ?? _loadData, + child: filteredTransactions.isEmpty + ? ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(16.0), children: [ - Icon(Icons.receipt_outlined, size: 64, color: Colors.grey[400]), - const SizedBox(height: 16), - Text( - widget.searchQuery.isNotEmpty - ? 'Tidak ada transaksi yang cocok' - : 'Belum ada transaksi', - style: TextStyle(fontSize: 18, color: Colors.grey[600]), - ), - if (widget.searchQuery.isEmpty) - Text( - 'Tap + untuk menambah transaksi baru', - style: TextStyle(color: Colors.grey[500]), + const SizedBox(height: 120), + Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.receipt_outlined, size: 64, color: Theme.of(context).textTheme.bodyMedium?.color?.withOpacity(0.5)), + const SizedBox(height: 16), + Text( + widget.searchQuery.isNotEmpty + ? 'Tidak ada transaksi yang cocok' + : 'Belum ada transaksi', + style: TextStyle(fontSize: 18, color: Theme.of(context).textTheme.bodyMedium?.color?.withOpacity(0.7)), + ), + if (widget.searchQuery.isEmpty) + Text( + 'Tap + untuk menambah transaksi baru', + style: TextStyle(color: Theme.of(context).textTheme.bodyMedium?.color?.withOpacity(0.6)), + ), + ], ), + ), ], + ) + : ListView.builder( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(16.0), + itemCount: filteredTransactions.length, + itemBuilder: (context, index) { + final transaction = filteredTransactions[index]; + return TransactionCard( + transaction: transaction, + onEdit: () => _editTransaction(transaction), + onDelete: () => _deleteTransaction(transaction), + ); + }, ), - ) - : ListView.builder( - padding: const EdgeInsets.all(16.0), - itemCount: filteredTransactions.length, - itemBuilder: (context, index) { - final transaction = filteredTransactions[index]; - return TransactionCard( - transaction: transaction, - onEdit: () => _editTransaction(transaction), - onDelete: () => _deleteTransaction(transaction), - ); - }, - ), + ), ), ], ), floatingActionButton: FloatingActionButton( + heroTag: 'fab-finance', onPressed: () => _addTransaction('income'), child: const Icon(Icons.add), ), ); } - Widget _buildSummaryCard(String title, double amount, Color color, IconData icon) { + Widget _buildSummaryCard(String title, String amount, Color backgroundColor, Color color, IconData icon) { return Card( child: Padding( padding: const EdgeInsets.all(12.0), @@ -655,7 +1078,7 @@ class _FinanceScreenState extends State { ), const SizedBox(height: 4), Text( - 'Rp ${amount.toStringAsFixed(0).replaceAllMapped(RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (Match m) => '${m[1]}.')}', + amount, style: TextStyle( fontSize: 14, fontWeight: FontWeight.bold, @@ -691,10 +1114,10 @@ class TransactionCard extends StatelessWidget { margin: const EdgeInsets.symmetric(vertical: 4.0), child: ListTile( leading: CircleAvatar( - backgroundColor: isIncome ? Colors.green.shade100 : Colors.red.shade100, + backgroundColor: isIncome ? Theme.of(context).colorScheme.primary.withOpacity(0.1) : Theme.of(context).colorScheme.error.withOpacity(0.1), child: Icon( isIncome ? Icons.trending_up : Icons.trending_down, - color: isIncome ? Colors.green : Colors.red, + color: isIncome ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.error, ), ), title: Text( @@ -712,21 +1135,21 @@ class TransactionCard extends StatelessWidget { Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( - color: isIncome ? Colors.green.shade100 : Colors.red.shade100, + color: isIncome ? Theme.of(context).colorScheme.primary.withOpacity(0.1) : Theme.of(context).colorScheme.error.withOpacity(0.1), borderRadius: BorderRadius.circular(12), ), child: Text( transaction.category, style: TextStyle( fontSize: 12, - color: isIncome ? Colors.green.shade700 : Colors.red.shade700, + color: isIncome ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.error, ), ), ), const Spacer(), Text( '${transaction.createdAt.day}/${transaction.createdAt.month}/${transaction.createdAt.year}', - style: const TextStyle(fontSize: 12, color: Colors.grey), + style: TextStyle(fontSize: 12, color: Theme.of(context).textTheme.bodyMedium?.color?.withOpacity(0.6)), ), ], ), @@ -740,10 +1163,10 @@ class TransactionCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( - '${isIncome ? '+' : '-'}Rp ${transaction.amount.toStringAsFixed(0).replaceAllMapped(RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (Match m) => '${m[1]}.')}', + _formatCurrency(transaction.amount), style: TextStyle( fontWeight: FontWeight.bold, - color: isIncome ? Colors.green : Colors.red, + color: isIncome ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.error, fontSize: 14, ), ), @@ -751,10 +1174,18 @@ class TransactionCard extends StatelessWidget { ), PopupMenuButton( icon: const Icon(Icons.more_vert), + onSelected: (String value) { + if (value == 'edit') { + // Defer to next microtask to ensure the popup has fully closed + Future.microtask(onEdit); + } else if (value == 'delete') { + Future.microtask(onDelete); + } + }, itemBuilder: (context) => [ - PopupMenuItem( - onTap: onEdit, - child: const Row( + const PopupMenuItem( + value: 'edit', + child: Row( children: [ Icon(Icons.edit, size: 20), SizedBox(width: 8), @@ -766,9 +1197,9 @@ class TransactionCard extends StatelessWidget { onTap: onDelete, child: const Row( children: [ - Icon(Icons.delete, color: Colors.red, size: 20), - SizedBox(width: 8), - Text('Hapus', style: TextStyle(color: Colors.red)), + Icon(Icons.delete, color: Theme.of(context).colorScheme.error, size: 20), + const SizedBox(width: 8), + Text('Hapus', style: TextStyle(color: Theme.of(context).colorScheme.error)), ], ), ), @@ -829,8 +1260,8 @@ class _AddEditTransactionDialogState extends State { return; } - final amount = double.tryParse(_amountController.text.trim()); - if (amount == null || amount <= 0) { + final amount = _parseFormattedAmount(_amountController.text.trim()); + if (amount <= 0) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Jumlah harus berupa angka yang valid!')), ); @@ -869,71 +1300,70 @@ class _AddEditTransactionDialogState extends State { mainAxisSize: MainAxisSize.min, children: [ if (widget.transaction == null) - Row( - children: [ - Expanded( - child: RadioListTile( - dense: true, - title: const Text('Pemasukan'), - value: 'income', - groupValue: _transactionType, - onChanged: (value) { - setState(() { - _transactionType = value!; - _selectedCategory = (value == 'income' ? widget.incomeCategories : widget.expenseCategories).first; - }); - }, - ), - ), - Expanded( - child: RadioListTile( - dense: true, - title: const Text('Pengeluaran'), - value: 'expense', - groupValue: _transactionType, - onChanged: (value) { - setState(() { - _transactionType = value!; - _selectedCategory = (value == 'income' ? widget.incomeCategories : widget.expenseCategories).first; - }); - }, - ), - ), - ], + DropdownButtonFormField( + value: _transactionType, + decoration: InputDecoration( + labelText: 'Jenis Transaksi', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(25), + ), ), + items: const [ + DropdownMenuItem(child: Text('Pemasukan'), value: 'income'), + DropdownMenuItem(child: Text('Pengeluaran'), value: 'expense'), + ], + onChanged: (value) { + setState(() { + _transactionType = value!; + _selectedCategory = (value == 'income' ? widget.incomeCategories : widget.expenseCategories).first; + }); + }, + ), const SizedBox(height: 16), TextField( controller: _titleController, - decoration: const InputDecoration( + decoration: InputDecoration( labelText: 'Judul Transaksi', - border: OutlineInputBorder(), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(25), + ), ), ), const SizedBox(height: 16), TextField( controller: _amountController, keyboardType: TextInputType.number, - decoration: const InputDecoration( + inputFormatters: [ + ThousandsSeparatorInputFormatter(), + ], + decoration: InputDecoration( labelText: 'Jumlah (Rp)', - border: OutlineInputBorder(), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(25), + ), prefixText: 'Rp ', + hintText: '1.000.000', ), ), const SizedBox(height: 16), TextField( controller: _descriptionController, - decoration: const InputDecoration( + decoration: InputDecoration( labelText: 'Deskripsi (opsional)', - border: OutlineInputBorder(), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(25), + ), ), maxLines: 3, ), const SizedBox(height: 16), DropdownButtonFormField( value: _selectedCategory, - decoration: const InputDecoration( + decoration: InputDecoration( labelText: 'Kategori', - border: OutlineInputBorder(), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(25), + ), ), items: categories.map((category) { return DropdownMenuItem( @@ -976,8 +1406,9 @@ class _AddEditTransactionDialogState extends State { // Todo List Screen (tetap sama seperti sebelumnya) class TodoListScreen extends StatefulWidget { final String searchQuery; + final Future Function()? onGlobalRefresh; - const TodoListScreen({Key? key, required this.searchQuery}) : super(key: key); + const TodoListScreen({Key? key, required this.searchQuery, this.onGlobalRefresh}) : super(key: key); @override State createState() => _TodoListScreenState(); @@ -987,6 +1418,7 @@ class _TodoListScreenState extends State { List tasks = []; List categories = []; String selectedCategory = 'Semua'; + bool isLoading = true; @override void initState() { @@ -995,14 +1427,22 @@ class _TodoListScreenState extends State { } Future _loadData() async { + setState(() { + isLoading = true; + }); + final loadedTasks = await DataService.loadTasks(); final loadedCategories = await DataService.loadTaskCategories(); setState(() { tasks = loadedTasks; categories = ['Semua'] + loadedCategories; + isLoading = false; }); } + // Expose public refresh for global trigger + Future refresh() => _loadData(); + Future _saveTasks() async { await DataService.saveTasks(tasks); } @@ -1079,7 +1519,7 @@ class _TodoListScreenState extends State { _saveTasks(); Navigator.pop(context); }, - child: const Text('Hapus', style: TextStyle(color: Colors.red)), + child: Text('Hapus', style: TextStyle(color: Theme.of(context).colorScheme.error)), ), ], ), @@ -1116,6 +1556,21 @@ class _TodoListScreenState extends State { @override Widget build(BuildContext context) { + if (isLoading) { + return const Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Memuat tasks...'), + ], + ), + ), + ); + } + return Scaffold( body: Column( children: [ @@ -1149,45 +1604,57 @@ class _TodoListScreenState extends State { ), ), Expanded( - child: filteredTasks.isEmpty - ? Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, + child: RefreshIndicator( + onRefresh: widget.onGlobalRefresh ?? _loadData, + child: filteredTasks.isEmpty + ? ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(16.0), children: [ - Icon(Icons.task_alt, size: 64, color: Colors.grey[400]), - const SizedBox(height: 16), - Text( - widget.searchQuery.isNotEmpty - ? 'Tidak ada task yang cocok' - : 'Belum ada task', - style: TextStyle(fontSize: 18, color: Colors.grey[600]), - ), - if (widget.searchQuery.isEmpty) - Text( - 'Tap + untuk menambah task baru', - style: TextStyle(color: Colors.grey[500]), + const SizedBox(height: 120), + Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.task_alt, size: 64, color: Colors.grey[400]), + const SizedBox(height: 16), + Text( + widget.searchQuery.isNotEmpty + ? 'Tidak ada task yang cocok' + : 'Belum ada task', + style: TextStyle(fontSize: 18, color: Colors.grey[600]), + ), + if (widget.searchQuery.isEmpty) + Text( + 'Tap + untuk menambah task baru', + style: TextStyle(color: Colors.grey[500]), + ), + ], ), + ), ], + ) + : ListView.builder( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(8.0), + itemCount: filteredTasks.length, + itemBuilder: (context, index) { + final task = filteredTasks[index]; + return TaskCard( + task: task, + onToggleComplete: () => _toggleTaskComplete(task), + onToggleSubTaskComplete: (subTask) => _toggleSubTaskComplete(task, subTask), + onEdit: () => _editTask(task), + onDelete: () => _deleteTask(task), + ); + }, ), - ) - : ListView.builder( - padding: const EdgeInsets.all(8.0), - itemCount: filteredTasks.length, - itemBuilder: (context, index) { - final task = filteredTasks[index]; - return TaskCard( - task: task, - onToggleComplete: () => _toggleTaskComplete(task), - onToggleSubTaskComplete: (subTask) => _toggleSubTaskComplete(task, subTask), - onEdit: () => _editTask(task), - onDelete: () => _deleteTask(task), - ); - }, - ), + ), ), ], ), floatingActionButton: FloatingActionButton( + heroTag: 'fab-tasks', onPressed: _addTask, child: const Icon(Icons.add), ), @@ -1198,8 +1665,9 @@ class _TodoListScreenState extends State { // Notes Screen (tetap sama seperti sebelumnya) class NotesScreen extends StatefulWidget { final String searchQuery; + final Future Function()? onGlobalRefresh; - const NotesScreen({Key? key, required this.searchQuery}) : super(key: key); + const NotesScreen({Key? key, required this.searchQuery, this.onGlobalRefresh}) : super(key: key); @override State createState() => _NotesScreenState(); @@ -1209,6 +1677,7 @@ class _NotesScreenState extends State { List notes = []; List categories = []; String selectedCategory = 'Semua'; + bool isLoading = true; bool isGridView = false; @override @@ -1218,14 +1687,22 @@ class _NotesScreenState extends State { } Future _loadData() async { + setState(() { + isLoading = true; + }); + final loadedNotes = await DataService.loadNotes(); final loadedCategories = await DataService.loadNoteCategories(); setState(() { notes = loadedNotes; categories = ['Semua'] + loadedCategories; + isLoading = false; }); } + // Expose public refresh for global trigger + Future refresh() => _loadData(); + Future _saveNotes() async { await DataService.saveNotes(notes); } @@ -1302,7 +1779,7 @@ class _NotesScreenState extends State { _saveNotes(); Navigator.pop(context); }, - child: const Text('Hapus', style: TextStyle(color: Colors.red)), + child: Text('Hapus', style: TextStyle(color: Theme.of(context).colorScheme.error)), ), ], ), @@ -1311,6 +1788,21 @@ class _NotesScreenState extends State { @override Widget build(BuildContext context) { + if (isLoading) { + return const Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Memuat notes...'), + ], + ), + ), + ); + } + return Scaffold( body: Column( children: [ @@ -1352,62 +1844,75 @@ class _NotesScreenState extends State { ), ), Expanded( - child: filteredNotes.isEmpty - ? Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, + child: RefreshIndicator( + onRefresh: widget.onGlobalRefresh ?? _loadData, + child: filteredNotes.isEmpty + ? ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(16.0), children: [ - Icon(Icons.note_outlined, size: 64, color: Colors.grey[400]), - const SizedBox(height: 16), - Text( - widget.searchQuery.isNotEmpty - ? 'Tidak ada note yang cocok' - : 'Belum ada note', - style: TextStyle(fontSize: 18, color: Colors.grey[600]), - ), - if (widget.searchQuery.isEmpty) - Text( - 'Tap + untuk menambah note baru', - style: TextStyle(color: Colors.grey[500]), + const SizedBox(height: 120), + Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.note_outlined, size: 64, color: Colors.grey[400]), + const SizedBox(height: 16), + Text( + widget.searchQuery.isNotEmpty + ? 'Tidak ada note yang cocok' + : 'Belum ada note', + style: TextStyle(fontSize: 18, color: Colors.grey[600]), + ), + if (widget.searchQuery.isEmpty) + Text( + 'Tap + untuk menambah note baru', + style: TextStyle(color: Colors.grey[500]), + ), + ], ), + ), ], - ), - ) - : isGridView - ? GridView.builder( - padding: const EdgeInsets.all(8.0), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - childAspectRatio: 0.8, - crossAxisSpacing: 8, - mainAxisSpacing: 8, + ) + : isGridView + ? GridView.builder( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(8.0), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + childAspectRatio: 0.8, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), + itemCount: filteredNotes.length, + itemBuilder: (context, index) { + final note = filteredNotes[index]; + return NoteGridCard( + note: note, + onTap: () => _editNote(note), + onDelete: () => _deleteNote(note), + ); + }, + ) + : ListView.builder( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(8.0), + itemCount: filteredNotes.length, + itemBuilder: (context, index) { + final note = filteredNotes[index]; + return NoteListCard( + note: note, + onTap: () => _editNote(note), + onDelete: () => _deleteNote(note), + ); + }, ), - itemCount: filteredNotes.length, - itemBuilder: (context, index) { - final note = filteredNotes[index]; - return NoteGridCard( - note: note, - onTap: () => _editNote(note), - onDelete: () => _deleteNote(note), - ); - }, - ) - : ListView.builder( - padding: const EdgeInsets.all(8.0), - itemCount: filteredNotes.length, - itemBuilder: (context, index) { - final note = filteredNotes[index]; - return NoteListCard( - note: note, - onTap: () => _editNote(note), - onDelete: () => _deleteNote(note), - ); - }, - ), + ), ), ], ), floatingActionButton: FloatingActionButton( + heroTag: 'fab-notes', onPressed: _addNote, child: const Icon(Icons.add), ), @@ -1799,26 +2304,32 @@ class _AddEditTaskDialogState extends State { children: [ TextField( controller: _titleController, - decoration: const InputDecoration( + decoration: InputDecoration( labelText: 'Judul Task', - border: OutlineInputBorder(), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(25), + ), ), ), const SizedBox(height: 16), TextField( controller: _descriptionController, - decoration: const InputDecoration( + decoration: InputDecoration( labelText: 'Deskripsi (opsional)', - border: OutlineInputBorder(), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(25), + ), ), maxLines: 3, ), const SizedBox(height: 16), DropdownButtonFormField( value: _selectedCategory, - decoration: const InputDecoration( + decoration: InputDecoration( labelText: 'Kategori', - border: OutlineInputBorder(), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(25), + ), ), items: widget.categories.map((category) { return DropdownMenuItem( @@ -1846,9 +2357,11 @@ class _AddEditTaskDialogState extends State { Expanded( child: TextField( controller: _subTaskController, - decoration: const InputDecoration( + decoration: InputDecoration( hintText: 'Tambah subtask', - border: OutlineInputBorder(), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(25), + ), ), onSubmitted: (_) => _addSubTask(), ), @@ -1976,17 +2489,21 @@ class _AddEditNoteDialogState extends State { children: [ TextField( controller: _titleController, - decoration: const InputDecoration( + decoration: InputDecoration( labelText: 'Judul Note', - border: OutlineInputBorder(), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(25), + ), ), ), const SizedBox(height: 16), TextField( controller: _contentController, - decoration: const InputDecoration( + decoration: InputDecoration( labelText: 'Isi Note', - border: OutlineInputBorder(), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(25), + ), alignLabelWithHint: true, ), maxLines: 8, @@ -1994,9 +2511,11 @@ class _AddEditNoteDialogState extends State { const SizedBox(height: 16), DropdownButtonFormField( value: _selectedCategory, - decoration: const InputDecoration( + decoration: InputDecoration( labelText: 'Kategori', - border: OutlineInputBorder(), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(25), + ), ), items: widget.categories.map((category) { return DropdownMenuItem( @@ -2011,7 +2530,7 @@ class _AddEditNoteDialogState extends State { }, ), const SizedBox(height: 16), - const Align( + Align( alignment: Alignment.centerLeft, child: Text( 'Warna Note:', diff --git a/lib/page/login.dart b/lib/page/login.dart new file mode 100644 index 0000000..0f3aafa --- /dev/null +++ b/lib/page/login.dart @@ -0,0 +1,338 @@ +import 'package:flutter/material.dart'; +import 'package:tlist/page/register.dart'; +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:flutter/foundation.dart'; +import 'package:google_sign_in/google_sign_in.dart'; +import 'package:tlist/main.dart'; + +class LoginPage extends StatefulWidget { + const LoginPage({super.key}); + + @override + State createState() => _LoginPageState(); +} + +class _LoginPageState extends State { + final _formKey = GlobalKey(); + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + bool _obscure = true; + static bool _googleInitialized = false; + + Future _submit() async { + if (!(_formKey.currentState?.validate() ?? false)) return; + FocusScope.of(context).unfocus(); + final email = _emailController.text.trim(); + final pass = _passwordController.text; + try { + await FirebaseAuth.instance.signInWithEmailAndPassword( + email: email, + password: pass, + ); + final user = FirebaseAuth.instance.currentUser; + await user?.reload(); + if (!(user?.emailVerified ?? false)) { + await user?.sendEmailVerification(); + await FirebaseAuth.instance.signOut(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Email belum terverifikasi. Link verifikasi telah dikirim ulang.'), + duration: Duration(seconds: 4), + ), + ); + return; + } + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Login berhasil')), + ); + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute(builder: (_) => const MainScreen()), + (route) => false, + ); + } on FirebaseAuthException catch (e) { + final msg = _humanizeAuthError(e.code); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(msg)), + ); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Terjadi kesalahan: $e')), + ); + } + } + + String _humanizeAuthError(String code) { + switch (code) { + case 'invalid-email': + return 'Email tidak valid'; + case 'user-disabled': + return 'Akun dinonaktifkan'; + case 'user-not-found': + return 'Pengguna tidak ditemukan'; + case 'wrong-password': + return 'Password salah'; + default: + return 'Login gagal ($code)'; + } + } + + Future _forgotPassword() async { + final emailController = TextEditingController(text: _emailController.text.trim()); + final formKey = GlobalKey(); + await showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Reset Password'), + content: Form( + key: formKey, + child: TextFormField( + controller: emailController, + keyboardType: TextInputType.emailAddress, + decoration: const InputDecoration( + labelText: 'Email terdaftar', + border: OutlineInputBorder(), + ), + validator: (value) { + final v = value?.trim() ?? ''; + if (v.isEmpty) return 'Email tidak boleh kosong'; + final emailRegex = RegExp(r'^.+@.+\..+$'); + if (!emailRegex.hasMatch(v)) return 'Format email tidak valid'; + return null; + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + ElevatedButton( + onPressed: () async { + if (!(formKey.currentState?.validate() ?? false)) return; + final email = emailController.text.trim(); + try { + await FirebaseAuth.instance.sendPasswordResetEmail(email: email); + if (mounted) { + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Link reset password telah dikirim ke email.')), + ); + } + } on FirebaseAuthException catch (e) { + final msg = e.code == 'user-not-found' + ? 'Email tidak terdaftar' + : e.code == 'invalid-email' + ? 'Email tidak valid' + : 'Gagal mengirim email reset: ${e.code}'; + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(msg)), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Terjadi kesalahan: $e')), + ); + } + } + }, + child: const Text('Kirim Link'), + ), + ], + ); + }, + ); + } + + Future _signInWithGoogle() async { + try { + if (kIsWeb) { + final provider = GoogleAuthProvider(); + final hint = _emailController.text.trim(); + if (hint.isNotEmpty) { + provider.setCustomParameters({'login_hint': hint}); + } + await FirebaseAuth.instance.signInWithPopup(provider); + } else { + if (!_googleInitialized) { + await GoogleSignIn.instance.initialize( + serverClientId: + '582262425557-doq1ic0ia81krqmelq24lkb6sh4oaef2.apps.googleusercontent.com', + ); + _googleInitialized = true; + } + final account = await GoogleSignIn.instance.authenticate(); + final googleAuth = await account.authentication; + final credential = GoogleAuthProvider.credential( + idToken: googleAuth.idToken, + ); + await FirebaseAuth.instance.signInWithCredential(credential); + } + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Login Google berhasil')), + ); + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute(builder: (_) => const MainScreen()), + (route) => false, + ); + } on FirebaseAuthException catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Login Google gagal: ${e.code}')), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Login Google gagal: $e')), + ); + } + } + + @override + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Login'), + ), + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 24), + Center( + child: Image.asset( + 'assets/logo.png', + height: 80, + ), + ), + const SizedBox(height: 12), + Text( + 'Selamat Datang Kembali', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 24), + TextFormField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.next, + decoration: InputDecoration( + labelText: 'Email', + hintText: 'you@example.com', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(25), + ), + prefixIcon: Icon(Icons.email_outlined), + ), + validator: (value) { + final v = value?.trim() ?? ''; + if (v.isEmpty) return 'Email tidak boleh kosong'; + final emailRegex = RegExp(r'^.+@.+\..+$'); + if (!emailRegex.hasMatch(v)) return 'Format email tidak valid'; + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _passwordController, + obscureText: _obscure, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _submit(), + decoration: InputDecoration( + labelText: 'Password', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(25), + ), + prefixIcon: Icon(Icons.lock_outline), + suffixIcon: IconButton( + onPressed: () => setState(() => _obscure = !_obscure), + icon: Icon(_obscure ? Icons.visibility : Icons.visibility_off), + tooltip: _obscure ? 'Tampilkan' : 'Sembunyikan', + ), + ), + validator: (value) { + final v = value ?? ''; + if (v.isEmpty) return 'Password tidak boleh kosong'; + if (v.length < 6) return 'Minimal 6 karakter'; + return null; + }, + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: _submit, + style: ElevatedButton.styleFrom( + minimumSize: const Size.fromHeight(48), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(25), + ), + ), + child: const Text('Login'), + ), + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: _signInWithGoogle, + icon: const Image( + image: AssetImage('assets/google.png'), + width: 24, + height: 24, + ), + label: const Text('Lanjutkan dengan Google'), + style: OutlinedButton.styleFrom( + minimumSize: const Size.fromHeight(48), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(25), + ), + ), + ), + const SizedBox(height: 12), + Divider(color: Theme.of(context).dividerColor.withOpacity(0.5)), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('Belum punya akun? '), + TextButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const RegisterPage(), + ), + ); + }, + child: const Text('Daftar'), + ), + Text(' | ', style: TextStyle(color: Theme.of(context).textTheme.bodyMedium?.color?.withOpacity(0.6))), + TextButton( + onPressed: _forgotPassword, + child: const Text('Lupa password?'), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/page/register.dart b/lib/page/register.dart new file mode 100644 index 0000000..cd806e8 --- /dev/null +++ b/lib/page/register.dart @@ -0,0 +1,297 @@ +import 'package:flutter/foundation.dart'; +import 'package:google_sign_in/google_sign_in.dart'; +import 'package:flutter/material.dart'; +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:tlist/main.dart'; +import 'package:tlist/page/login.dart'; + +class RegisterPage extends StatefulWidget { + const RegisterPage({super.key}); + + @override + State createState() => _RegisterPageState(); +} + +class _RegisterPageState extends State { + final _formKey = GlobalKey(); + final _nameController = TextEditingController(); + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + final _confirmController = TextEditingController(); + + bool _obscurePass = true; + bool _obscureConfirm = true; + + void _submit() { + if (!(_formKey.currentState?.validate() ?? false)) return; + FocusScope.of(context).unfocus(); + _register(); + } + + Future _register() async { + final name = _nameController.text.trim(); + final email = _emailController.text.trim(); + final pass = _passwordController.text; + try { + final cred = await FirebaseAuth.instance.createUserWithEmailAndPassword( + email: email, + password: pass, + ); + await cred.user?.updateDisplayName(name); + await cred.user?.sendEmailVerification(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Registrasi berhasil. Cek email untuk verifikasi sebelum login.'), + duration: Duration(seconds: 4), + ), + ); + // Paksa sign out sampai email terverifikasi + await FirebaseAuth.instance.signOut(); + Navigator.pop(context); // kembali ke login + } on FirebaseAuthException catch (e) { + final msg = _humanizeAuthError(e.code); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(msg)), + ); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Terjadi kesalahan: $e')), + ); + } + } + + String _humanizeAuthError(String code) { + switch (code) { + case 'email-already-in-use': + return 'Email sudah terdaftar'; + case 'invalid-email': + return 'Email tidak valid'; + case 'operation-not-allowed': + return 'Operasi tidak diizinkan'; + case 'weak-password': + return 'Password terlalu lemah'; + default: + return 'Registrasi gagal ($code)'; + } + } + + @override + void dispose() { + _nameController.dispose(); + _emailController.dispose(); + _passwordController.dispose(); + _confirmController.dispose(); + super.dispose(); + } + + bool _googleInitialized = false; + + Future _signInWithGoogle() async { + try { + if (kIsWeb) { + final provider = GoogleAuthProvider(); + final hint = _emailController.text.trim(); + if (hint.isNotEmpty) { + provider.setCustomParameters({'login_hint': hint}); + } + await FirebaseAuth.instance.signInWithPopup(provider); + } else { + if (!_googleInitialized) { + await GoogleSignIn.instance.initialize( + serverClientId: + '582262425557-doq1ic0ia81krqmelq24lkb6sh4oaef2.apps.googleusercontent.com', + ); + _googleInitialized = true; + } + final account = await GoogleSignIn.instance.authenticate(); + final googleAuth = await account.authentication; + final credential = GoogleAuthProvider.credential( + idToken: googleAuth.idToken, + ); + await FirebaseAuth.instance.signInWithCredential(credential); + } + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Login Google berhasil')), + ); + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute(builder: (_) => const MainScreen()), + (route) => false, + ); + } on FirebaseAuthException catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Login Google gagal: ${e.code}')), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Login Google gagal: $e')), + ); + } + } + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Daftar')), + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 24), + Center( + child: Image.asset( + 'assets/logo.png', + height: 80, + ), + ), + const SizedBox(height: 12), + Text( + 'Buat Akun TList', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 24), + TextFormField( + controller: _nameController, + textInputAction: TextInputAction.next, + decoration: InputDecoration( + labelText: 'Nama Lengkap', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(25), + ), + prefixIcon: Icon(Icons.person_outline), + ), + validator: (value) { + final v = (value ?? '').trim(); + if (v.isEmpty) return 'Nama tidak boleh kosong'; + if (v.length < 3) return 'Nama terlalu pendek'; + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.next, + decoration: InputDecoration( + labelText: 'Email', + hintText: 'you@example.com', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(25), + ), + prefixIcon: Icon(Icons.email_outlined), + ), + validator: (value) { + final v = (value ?? '').trim(); + if (v.isEmpty) return 'Email tidak boleh kosong'; + final emailRegex = RegExp(r'^.+@.+\..+$'); + if (!emailRegex.hasMatch(v)) return 'Format email tidak valid'; + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _passwordController, + obscureText: _obscurePass, + textInputAction: TextInputAction.next, + decoration: InputDecoration( + labelText: 'Password', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(25), + ), + prefixIcon: const Icon(Icons.lock_outline), + suffixIcon: IconButton( + onPressed: () => setState(() => _obscurePass = !_obscurePass), + icon: Icon(_obscurePass ? Icons.visibility : Icons.visibility_off), + tooltip: _obscurePass ? 'Tampilkan' : 'Sembunyikan', + ), + ), + validator: (value) { + final v = value ?? ''; + if (v.isEmpty) return 'Password tidak boleh kosong'; + if (v.length < 6) return 'Minimal 6 karakter'; + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _confirmController, + obscureText: _obscureConfirm, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _submit(), + decoration: InputDecoration( + labelText: 'Konfirmasi Password', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(25), + ), + prefixIcon: const Icon(Icons.lock_outline), + suffixIcon: IconButton( + onPressed: () => setState(() => _obscureConfirm = !_obscureConfirm), + icon: Icon(_obscureConfirm ? Icons.visibility : Icons.visibility_off), + tooltip: _obscureConfirm ? 'Tampilkan' : 'Sembunyikan', + ), + ), + validator: (value) { + final v = value ?? ''; + if (v.isEmpty) return 'Konfirmasi password tidak boleh kosong'; + if (v != _passwordController.text) return 'Password tidak cocok'; + return null; + }, + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: _submit, + style: ElevatedButton.styleFrom( + minimumSize: const Size.fromHeight(48), + ), + child: const Text('Daftar'), + ), + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: _signInWithGoogle, + icon: const Image( + image: AssetImage('assets/google.png'), + width: 24, + height: 24, + ), + label: const Text('Lanjutkan dengan Google'), + style: OutlinedButton.styleFrom( + minimumSize: const Size.fromHeight(48), + ), + ), + const SizedBox(height: 12), + const Divider(), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('Sudah punya akun?'), + TextButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const LoginPage(), + ), + ); + }, + child: const Text('Login'), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/page/user.dart b/lib/page/user.dart new file mode 100644 index 0000000..c532eab --- /dev/null +++ b/lib/page/user.dart @@ -0,0 +1,470 @@ +import 'package:flutter/material.dart'; +import 'package:tlist/page/login.dart'; +import 'package:tlist/page/register.dart'; +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:flutter/foundation.dart'; +import 'package:tlist/utils/app_update.dart'; + +class UserPage extends StatelessWidget { + const UserPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('User')), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: StreamBuilder( + stream: FirebaseAuth.instance.authStateChanges(), + builder: (context, snapshot) { + final user = snapshot.data; + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + if (user == null) { + return _buildSignedOut(context); + } + return _buildSignedIn(context, user); + }, + ), + ), + ), + ); + } + + Widget _buildSignedOut(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 24), + Center( + child: Image.asset( + 'assets/logo.png', + height: 80, + ), + ), + const SizedBox(height: 12), + Text( + 'Kamu Belum Login', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const LoginPage()), + ); + }, + style: ElevatedButton.styleFrom(minimumSize: const Size.fromHeight(48)), + child: const Text('Login'), + ), + const SizedBox(height: 12), + OutlinedButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const RegisterPage()), + ); + }, + style: OutlinedButton.styleFrom(minimumSize: const Size.fromHeight(48)), + child: const Text('Daftar'), + ), + Divider(color: Theme.of(context).dividerColor.withOpacity(0.5)), + ListTile( + leading: const Icon(Icons.system_update_alt), + title: const Text('Cek pembaruan'), + subtitle: const Text('Periksa apakah ada versi terbaru'), + onTap: () async { + final manifestUrl = kIsWeb + ? Uri.base.resolve('update.json').toString() + : 'https://tlistserver.web.app/update.json'; + final cfg = AppUpdateConfig(manifestUrl: manifestUrl); + final status = await AppUpdate.getStatus(config: cfg); + if (!context.mounted) return; + final info = status.info; + if (info == null) { + // gagal memuat manifest + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Gagal memeriksa pembaruan'), + content: const Text('Tidak bisa memuat informasi pembaruan saat ini. Coba lagi nanti.'), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('Tutup'), + ), + ], + ), + ); + return; + } + + if (!status.isNewer) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Sudah versi terbaru'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Versi terpasang: ${status.currentVersion}'), + Text('Versi terbaru: ${info.version}'), + const SizedBox(height: 8), + if ((info.notes ?? '').isNotEmpty) + Text(info.notes!), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('OK'), + ), + ], + ), + ); + } else { + await AppUpdate.checkAndPrompt( + context, + config: cfg, + silentOnError: false, + ); + } + }, + ), + // ListTile( + // leading: const Icon(Icons.system_update_alt), + // title: const Text('Cek pembaruan'), + // subtitle: const Text('Periksa apakah ada versi terbaru'), + // onTap: () async { + // final manifestUrl = kIsWeb + // ? Uri.base.resolve('update.json').toString() // same-origin to avoid CORS in web + // : 'https://tlistserver.web.app/update.json'; + // final cfg = AppUpdateConfig(manifestUrl: manifestUrl); + // // Manual check: tampilkan prompt meskipun sebelumnya pernah di-dismiss. + // await AppUpdate.checkAndPrompt( + // context, + // config: cfg, + // silentOnError: false, + // ignoreDismiss: true, + // ); + // }, + // ), + ], + ); + } + + Widget _buildSignedIn(BuildContext context, User user) { + final displayName = user.displayName ?? 'Pengguna'; + final email = user.email ?? '-'; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 16), + ListTile( + leading: const CircleAvatar(child: Icon(Icons.person)), + title: Text(displayName), + subtitle: Text(email), + ), + Divider(color: Theme.of(context).dividerColor.withOpacity(0.5)), + if (!(user.emailVerified)) + ListTile( + leading: const Icon(Icons.mark_email_unread_outlined), + title: const Text('Kirim ulang verifikasi email'), + onTap: () async { + try { + final acs = kIsWeb + ? ActionCodeSettings( + url: '${Uri.base.origin}/verified', + handleCodeInApp: false, + ) + : null; + if (acs != null) { + await user.sendEmailVerification(acs); + } else { + await user.sendEmailVerification(); + } + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Email verifikasi dikirim')), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Gagal kirim verifikasi: $e')), + ); + } + } + }, + ), + ListTile( + leading: const Icon(Icons.person_outline), + title: const Text('Ganti nama tampil'), + onTap: () async { + final name = await _prompt(context, title: 'Nama tampil baru', hint: 'Nama lengkap'); + if (name == null || name.trim().isEmpty) return; + try { + await user.updateDisplayName(name.trim()); + await user.reload(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Nama tampil diperbarui')), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Gagal ganti nama: $e')), + ); + } + } + }, + ), + ListTile( + leading: const Icon(Icons.alternate_email), + title: const Text('Ganti email'), + subtitle: const Text('Butuh verifikasi ulang, Anda akan keluar'), + onTap: () async { + final currentPass = await _prompt(context, title: 'Konfirmasi Password', hint: 'Password saat ini', obscure: true); + if (currentPass == null || currentPass.isEmpty) return; + final newEmail = await _prompt(context, title: 'Email baru', hint: 'nama@domain.com'); + if (newEmail == null || newEmail.trim().isEmpty) return; + try { + final emailNow = user.email; + if (emailNow == null) throw Exception('Email akun tidak tersedia'); + final cred = EmailAuthProvider.credential(email: emailNow, password: currentPass); + await user.reauthenticateWithCredential(cred); + final newAddr = newEmail.trim(); + final acs = kIsWeb + ? ActionCodeSettings( + url: '${Uri.base.origin}/verified', + handleCodeInApp: false, + ) + : null; + if (acs != null) { + await user.verifyBeforeUpdateEmail(newAddr, acs); + } else { + await user.verifyBeforeUpdateEmail(newAddr); + } + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Verifikasi telah dikirim ke email baru. Selesaikan verifikasi lalu login kembali.')), + ); + } + await FirebaseAuth.instance.signOut(); + } on FirebaseAuthException catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Gagal ganti email: ${e.code}')), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Gagal ganti email: $e')), + ); + } + } + }, + ), + ListTile( + leading: const Icon(Icons.lock_outline), + title: const Text('Ganti password'), + onTap: () async { + final currentPass = await _prompt(context, title: 'Password saat ini', hint: 'Password', obscure: true); + if (currentPass == null || currentPass.isEmpty) return; + final newPass = await _prompt(context, title: 'Password baru', hint: 'Minimal 6 karakter', obscure: true); + if (newPass == null || newPass.length < 6) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Password baru minimal 6 karakter')), + ); + } + return; + } + try { + final emailNow = user.email; + if (emailNow == null) throw Exception('Email akun tidak tersedia'); + final cred = EmailAuthProvider.credential(email: emailNow, password: currentPass); + await user.reauthenticateWithCredential(cred); + await user.updatePassword(newPass); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Password berhasil diubah')), + ); + } + } on FirebaseAuthException catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Gagal ganti password: ${e.code}')), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Gagal ganti password: $e')), + ); + } + } + }, + ), + ListTile( + leading: const Icon(Icons.logout), + title: const Text('Keluar'), + onTap: () async { + final confirm = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Konfirmasi'), + content: const Text('Yakin ingin keluar?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Batal'), + ), + ElevatedButton( + onPressed: () => Navigator.of(ctx).pop(true), + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.error, + foregroundColor: Colors.white, + ), + child: const Text('Keluar'), + ), + ], + ), + ); + + if (confirm == true) { + await FirebaseAuth.instance.signOut(); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Signed out')), + ); + } + }, + ), + Divider(color: Theme.of(context).dividerColor.withOpacity(0.5)), + ListTile( + leading: const Icon(Icons.system_update_alt), + title: const Text('Cek pembaruan'), + subtitle: const Text('Periksa apakah ada versi terbaru'), + onTap: () async { + final manifestUrl = kIsWeb + ? Uri.base.resolve('update.json').toString() + : 'https://tlistserver.web.app/update.json'; + final cfg = AppUpdateConfig(manifestUrl: manifestUrl); + final status = await AppUpdate.getStatus(config: cfg); + if (!context.mounted) return; + final info = status.info; + if (info == null) { + // gagal memuat manifest + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Gagal memeriksa pembaruan'), + content: const Text('Tidak bisa memuat informasi pembaruan saat ini. Coba lagi nanti.'), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('Tutup'), + ), + ], + ), + ); + return; + } + + if (!status.isNewer) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Sudah versi terbaru'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Versi terpasang: ${status.currentVersion}'), + Text('Versi terbaru: ${info.version}'), + const SizedBox(height: 8), + if ((info.notes ?? '').isNotEmpty) + Text(info.notes!), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('OK'), + ), + ], + ), + ); + } else { + await AppUpdate.checkAndPrompt( + context, + config: cfg, + silentOnError: false, + ); + } + }, + ), + ], + ); + } + + Future _prompt( + BuildContext context, { + required String title, + required String hint, + bool obscure = false, + }) async { + final controller = TextEditingController(); + final result = await showDialog( + context: context, + builder: (context) { + bool isObscure = obscure; + return StatefulBuilder( + builder: (ctx, setState) { + return AlertDialog( + title: Text(title), + content: TextField( + controller: controller, + obscureText: isObscure, + decoration: InputDecoration( + hintText: hint, + suffixIcon: obscure + ? IconButton( + icon: Icon(isObscure ? Icons.visibility_off : Icons.visibility), + onPressed: () => setState(() => isObscure = !isObscure), + ) + : null, + ), + textInputAction: TextInputAction.done, + onSubmitted: (_) => Navigator.pop(ctx, controller.text.trim()), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Batal'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(ctx, controller.text.trim()), + child: const Text('OK'), + ), + ], + ); + }, + ); + }, + ); + controller.dispose(); + return result; + } + +} diff --git a/lib/utils/app_update.dart b/lib/utils/app_update.dart new file mode 100644 index 0000000..0027a5c --- /dev/null +++ b/lib/utils/app_update.dart @@ -0,0 +1,248 @@ +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class AppUpdateConfig { + final String manifestUrl; + // Optional: key names in manifest + const AppUpdateConfig({required this.manifestUrl}); +} + +class AppUpdateInfo { + final String version; // e.g. 1.2.0+5 + final String? title; + final String? notes; + final String? url; // generic fallback + final String? urlAndroid; + final String? urlWindows; + final String? urlWeb; + final String? minForce; // if set and > current, force update + + AppUpdateInfo({ + required this.version, + this.title, + this.notes, + this.url, + this.urlAndroid, + this.urlWindows, + this.urlWeb, + this.minForce, + }); + + factory AppUpdateInfo.fromJson(Map j) { + return AppUpdateInfo( + version: (j['version'] ?? '').toString(), + title: j['title'] as String?, + notes: j['notes'] as String?, + url: j['url'] as String?, + urlAndroid: j['url_android'] as String?, + urlWindows: j['url_windows'] as String?, + urlWeb: j['url_web'] as String?, + minForce: j['min_force'] as String?, + ); + } +} + +class UpdateCheckResult { + final String currentVersion; + final AppUpdateInfo? info; + final bool isNewer; + final bool forced; + + UpdateCheckResult({ + required this.currentVersion, + required this.info, + required this.isNewer, + required this.forced, + }); +} + +class AppUpdate { + static const _prefsDismissKey = 'dismissed_update_version'; + + static Future checkAndPrompt( + BuildContext context, { + required AppUpdateConfig config, + bool silentOnError = true, + bool ignoreDismiss = true, + }) async { + try { + final info = await _fetchUpdateInfo(config.manifestUrl); + if (info == null) return; + + final pkg = await PackageInfo.fromPlatform(); + final currentVersion = '${pkg.version}+${pkg.buildNumber}'; + + final isNewer = _isRemoteNewer(currentVersion, info.version); + if (!isNewer) return; + + final prefs = await SharedPreferences.getInstance(); + final dismissed = prefs.getString(_prefsDismissKey); + + final forced = info.minForce != null && _isRemoteNewer(currentVersion, info.minForce!); + if (!forced && !ignoreDismiss && dismissed == info.version) { + return; // already dismissed this optional version + } + + if (!context.mounted) return; + await _showDialog(context, info, forced: forced, onDismissRemember: () async { + await prefs.setString(_prefsDismissKey, info.version); + }); + } catch (e) { + if (!silentOnError) { + // Optionally log + debugPrint('Update check error: $e'); + } + } + } + + static Future _fetchUpdateInfo(String url) async { + final res = await http.get(Uri.parse(url)).timeout(const Duration(seconds: 10)); + if (res.statusCode != 200) return null; + final data = json.decode(res.body) as Map; + return AppUpdateInfo.fromJson(data); + } + + static bool _isRemoteNewer(String current, String remote) { + // Parse like 1.2.3+45 into [1,2,3,45] + List parse(String v) { + final parts = v.split('+'); + final ver = parts[0]; + final build = parts.length > 1 ? int.tryParse(parts[1]) ?? 0 : 0; + final nums = ver.split('.').map((e) => int.tryParse(e) ?? 0).toList(); + while (nums.length < 3) nums.add(0); + nums.add(build); + return nums; + } + + final a = parse(current); + final b = parse(remote); + for (int i = 0; i < a.length && i < b.length; i++) { + if (b[i] > a[i]) return true; + if (b[i] < a[i]) return false; + } + return false; // equal + } + + /// Fetch update status along with manifest details and current version. + static Future getStatus({ + required AppUpdateConfig config, + }) async { + try { + final info = await _fetchUpdateInfo(config.manifestUrl); + final pkg = await PackageInfo.fromPlatform(); + final currentVersion = '${pkg.version}+${pkg.buildNumber}'; + final isNewer = info != null ? _isRemoteNewer(currentVersion, info.version) : false; + final forced = info != null && info.minForce != null + ? _isRemoteNewer(currentVersion, info.minForce!) + : false; + return UpdateCheckResult( + currentVersion: currentVersion, + info: info, + isNewer: isNewer, + forced: forced, + ); + } catch (_) { + final pkg = await PackageInfo.fromPlatform(); + return UpdateCheckResult( + currentVersion: '${pkg.version}+${pkg.buildNumber}', + info: null, + isNewer: false, + forced: false, + ); + } + } + + /// Returns true if a newer version than the currently installed app is available. + static Future isUpdateAvailable({ + required AppUpdateConfig config, + }) async { + try { + final info = await _fetchUpdateInfo(config.manifestUrl); + if (info == null) return false; + final pkg = await PackageInfo.fromPlatform(); + final currentVersion = '${pkg.version}+${pkg.buildNumber}'; + return _isRemoteNewer(currentVersion, info.version); + } catch (_) { + return false; + } + } + + static Future _showDialog( + BuildContext context, + AppUpdateInfo info, { + required bool forced, + required Future Function() onDismissRemember, + }) async { + final title = info.title ?? 'Pembaruan Tersedia'; + final notes = info.notes ?? 'Versi baru: ${info.version}'; + + return showDialog( + context: context, + barrierDismissible: !forced, + builder: (ctx) { + return WillPopScope( + onWillPop: () async => !forced, + child: AlertDialog( + title: Text(title), + content: Text(notes), + actions: [ + if (!forced) + TextButton( + onPressed: () async { + await onDismissRemember(); + if (context.mounted) Navigator.of(ctx).pop(); + }, + child: const Text('Nanti'), + ), + ElevatedButton( + onPressed: () async { + final url = info.url ?? info.urlAndroid ?? info.urlWindows ?? info.urlWeb; + final opened = await _openUpdateUrl(context, url); + if (!forced) { + if (context.mounted) Navigator.of(ctx).pop(); + } + }, + child: const Text('Update'), + ), + ], + ), + ); + }, + ); + } + + static Future _openUpdateUrl(BuildContext context, String? url) async { + if (url == null || url.isEmpty) { + _showSnack(context, 'URL update tidak tersedia'); + return false; + } + final uri = Uri.tryParse(url); + if (uri == null) { + _showSnack(context, 'URL update tidak valid'); + return false; + } + try { + if (kIsWeb) { + // Di web, buka tab baru agar tidak diblokir popup blocker + return await launchUrl(uri, webOnlyWindowName: '_blank'); + } else { + // Mobile/desktop: buka aplikasi eksternal (browser/store) + if (await canLaunchUrl(uri)) { + return await launchUrl(uri, mode: LaunchMode.externalApplication); + } + } + } catch (_) {} + _showSnack(context, 'Gagal membuka tautan update'); + return false; + } + + static void _showSnack(BuildContext context, String msg) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); + } +} diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index e71a16d..f6f23bf 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,10 @@ #include "generated_plugin_registrant.h" +#include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 2e1de87..f16b4c3 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 1fb4f92..df5cf49 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,12 +5,24 @@ import FlutterMacOS import Foundation +import cloud_firestore import firebase_auth import firebase_core +import firebase_database +import google_sign_in_ios +import package_info_plus +import path_provider_foundation import shared_preferences_foundation +import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FLTFirebaseFirestorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseFirestorePlugin")) FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) + FLTFirebaseDatabasePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseDatabasePlugin")) + FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 30d245f..c828dc4 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -89,6 +89,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + cloud_firestore: + dependency: "direct main" + description: + name: cloud_firestore + sha256: "78d22987093d89e875102753cba0335f13b1255086925941dacb96cd0b57866e" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + cloud_firestore_platform_interface: + dependency: transitive + description: + name: cloud_firestore_platform_interface + sha256: a9ce2edd81c3578d22a35933af2f56742e628a09dcb923750d2525d3152a823d + url: "https://pub.dev" + source: hosted + version: "7.0.0" + cloud_firestore_web: + dependency: transitive + description: + name: cloud_firestore_web + sha256: b751751ca29eb8ceff95c4f6c2d21bc895de968118e98235f5ffe45f0173ae24 + url: "https://pub.dev" + source: hosted + version: "5.0.0" collection: dependency: transitive description: @@ -193,6 +217,30 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + firebase_database: + dependency: "direct main" + description: + name: firebase_database + sha256: "4d8eb973f46af4dd481e89fec4f431805ff7415c8bf45ac895a302594c94506e" + url: "https://pub.dev" + source: hosted + version: "12.0.0" + firebase_database_platform_interface: + dependency: transitive + description: + name: firebase_database_platform_interface + sha256: "4f06fec2f58e6a33554c1509d63773c71b41ea3237643f17e72f7036d91f9f04" + url: "https://pub.dev" + source: hosted + version: "0.2.6+11" + firebase_database_web: + dependency: transitive + description: + name: firebase_database_web + sha256: faf6c2059b014d60ab574c1ce5afedc708ccb6bdad4d991c313c0705bba59c56 + url: "https://pub.dev" + source: hosted + version: "0.2.6+17" flutter: dependency: "direct main" description: flutter @@ -232,6 +280,62 @@ packages: description: flutter source: sdk version: "0.0.0" + google_identity_services_web: + dependency: transitive + description: + name: google_identity_services_web + sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454" + url: "https://pub.dev" + source: hosted + version: "0.3.3+1" + google_sign_in: + dependency: "direct main" + description: + name: google_sign_in + sha256: "939a8b58f84c4053811b8c1bc9adbcb59449a15b37958264bbf60020698cca0e" + url: "https://pub.dev" + source: hosted + version: "7.1.1" + google_sign_in_android: + dependency: transitive + description: + name: google_sign_in_android + sha256: f256b8f0e6c09d135c166fe20b25256e24d60fe1a72e6bdc112a200bd0d555b4 + url: "https://pub.dev" + source: hosted + version: "7.0.3" + google_sign_in_ios: + dependency: transitive + description: + name: google_sign_in_ios + sha256: c7ee744ebbcd98353966dbdee735d4fca085226f6bf725c6bea8a5c8fe0055bc + url: "https://pub.dev" + source: hosted + version: "6.1.0" + google_sign_in_platform_interface: + dependency: transitive + description: + name: google_sign_in_platform_interface + sha256: "8736443134d2cccadd4f228d600177cb3947e36683466a6ab96877ce6932885a" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + google_sign_in_web: + dependency: transitive + description: + name: google_sign_in_web + sha256: "09ac306b2787b48f19c857b9f93375b654f774643c75bd6a1a078c85f4f7b468" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + home_widget: + dependency: "direct main" + description: + name: home_widget + sha256: ad9634ef5894f3bac73f04d59e2e5151a39798f49985399fd928dadc828d974a + url: "https://pub.dev" + source: hosted + version: "0.8.0" html: dependency: transitive description: @@ -241,7 +345,7 @@ packages: source: hosted version: "0.15.6" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: "2c11f3f94c687ee9bad77c171151672986360b2b001d109814ee7140b2cf261b" @@ -264,6 +368,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.5.4" + intl: + dependency: "direct main" + description: + name: intl + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + url: "https://pub.dev" + source: hosted + version: "0.19.0" json_annotation: dependency: transitive description: @@ -328,6 +440,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.16.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968" + url: "https://pub.dev" + source: hosted + version: "8.3.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" path: dependency: transitive description: @@ -336,6 +464,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9 + url: "https://pub.dev" + source: hosted + version: "2.2.17" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" + url: "https://pub.dev" + source: hosted + version: "2.4.1" path_provider_linux: dependency: transitive description: @@ -517,6 +669,70 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.2" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "0aedad096a85b49df2e4725fa32118f9fa580f3b14af7a2d2221896a02cd5656" + url: "https://pub.dev" + source: hosted + version: "6.3.17" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "7f2022359d4c099eea7df3fdf739f7d3d3b9faf3166fb1dd390775176e0b76cb" + url: "https://pub.dev" + source: hosted + version: "6.3.3" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2" + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" + url: "https://pub.dev" + source: hosted + version: "3.1.4" vector_math: dependency: transitive description: @@ -541,6 +757,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03" + url: "https://pub.dev" + source: hosted + version: "5.14.0" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 72568d1..5637c25 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,9 +1,12 @@ name: tlist -description: "A new Flutter project." +description: "A Todo list app with some aditional features" +homepage: https://list.novila.xyz +repository: https://github.com/friyn/tlist -publish_to: 'none' # Remove this line if you wish to publish to pub.dev -version: 1.1.0+2 +publish_to: 'none' + +version: 1.5.0+150 environment: sdk: '>=3.0.0 <4.0.0' @@ -13,10 +16,17 @@ dependencies: sdk: flutter cupertino_icons: ^1.0.8 - + intl: ^0.19.0 shared_preferences: ^2.5.3 firebase_core: ^4.0.0 firebase_auth: ^6.0.0 + cloud_firestore: ^6.0.0 + firebase_database: ^12.0.0 + package_info_plus: ^8.0.2 + http: ^1.2.2 + url_launcher: ^6.3.0 + home_widget: ^0.8.0 + google_sign_in: ^7.1.1 dev_dependencies: flutter_test: @@ -29,6 +39,8 @@ dev_dependencies: flutter: uses-material-design: true + assets: + - assets/ flutter_icons: android: true diff --git a/web/favicon.png b/web/favicon.png index 8aaa46a..c3ea8bb 100644 Binary files a/web/favicon.png and b/web/favicon.png differ diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png index b749bfe..f945daf 100644 Binary files a/web/icons/Icon-192.png and b/web/icons/Icon-192.png differ diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png index 88cfd48..f3e94b9 100644 Binary files a/web/icons/Icon-512.png and b/web/icons/Icon-512.png differ diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png index eb9b4d7..f945daf 100644 Binary files a/web/icons/Icon-maskable-192.png and b/web/icons/Icon-maskable-192.png differ diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png index d69c566..f3e94b9 100644 Binary files a/web/icons/Icon-maskable-512.png and b/web/icons/Icon-maskable-512.png differ diff --git a/web/icons/light-4x.png b/web/icons/light-4x.png new file mode 100644 index 0000000..c3ea8bb Binary files /dev/null and b/web/icons/light-4x.png differ diff --git a/web/index.html b/web/index.html index 942ca66..e775e31 100644 --- a/web/index.html +++ b/web/index.html @@ -27,7 +27,7 @@ - tlist + TList diff --git a/web/manifest.json b/web/manifest.json index 1e5fabc..5032208 100644 --- a/web/manifest.json +++ b/web/manifest.json @@ -1,11 +1,11 @@ { - "name": "tlist", - "short_name": "tlist", + "name": "TList", + "short_name": "TList", "start_url": ".", "display": "standalone", "background_color": "#0175C2", "theme_color": "#0175C2", - "description": "A new Flutter project.", + "description": "A Todo list app with some aditional features", "orientation": "portrait-primary", "prefer_related_applications": false, "icons": [ @@ -32,4 +32,4 @@ "purpose": "maskable" } ] -} +} \ No newline at end of file diff --git a/web/privacy.html b/web/privacy.html new file mode 100644 index 0000000..3ebe283 --- /dev/null +++ b/web/privacy.html @@ -0,0 +1,86 @@ + + + + + + TList | Kebijakan Privasi + + + +
+

Kebijakan Privasi TList

+

Pembaruan terakhir: 16 Agustus 2025

+ +

Kebijakan Privasi ini menjelaskan bagaimana TList ("kami") mengumpulkan, menggunakan, menyimpan, melindungi, dan membagikan informasi pribadi Anda saat Anda menggunakan layanan kami ("Layanan"). Dengan menggunakan Layanan, Anda menyetujui praktik yang dijelaskan di sini.

+ +

1. Data yang Kami Kumpulkan

+
    +
  • Informasi Akun: nama, alamat email, dan sandi (disimpan dan dikelola oleh Firebase Authentication).
  • +
  • Masuk dengan Google: informasi profil dasar dari akun Google Anda (mis. nama, email, foto profil) serta token autentikasi untuk keperluan login.
  • +
  • Data Teknis: informasi perangkat dan penggunaan yang wajar untuk keperluan keamanan dan peningkatan Layanan (mis. alamat IP, tipe peramban, cap waktu), sebagaimana disediakan oleh platform yang Anda gunakan.
  • +
+ +

2. Cara Kami Menggunakan Data

+
    +
  • Menyediakan dan memelihara Layanan (autentikasi, manajemen akun).
  • +
  • Keamanan, pencegahan penyalahgunaan, dan pemulihan akun (mis. verifikasi email, reset sandi).
  • +
  • Peningkatan pengalaman pengguna dan dukungan.
  • +
  • Kepatuhan terhadap hukum yang berlaku.
  • +
+ +

3. Dasar Pemrosesan

+

Kami memroses data berdasarkan pelaksanaan kontrak (penyediaan Layanan), kepentingan sah (keamanan dan peningkatan), serta persetujuan Anda (mis. saat menggunakan Google Sign-In).

+ +

4. Berbagi Data dengan Pihak Ketiga

+

Kami menggunakan penyedia layanan pihak ketiga untuk menjalankan Layanan, termasuk:

+
    +
  • Firebase Authentication (oleh Google LLC) untuk autentikasi email/sandi dan pengelolaan kredensial.
  • +
  • Google Sign-In untuk proses masuk menggunakan akun Google.
  • +
+

Penggunaan Anda terhadap fitur tersebut juga tunduk pada ketentuan dan kebijakan privasi mereka.

+ +

5. Cookie dan Teknologi Serupa

+

Pada versi web, kami dapat menggunakan cookie, Local Storage, atau teknologi serupa untuk menjaga sesi login dan meningkatkan kinerja. Anda dapat menonaktifkannya melalui pengaturan peramban, namun beberapa fitur mungkin tidak berfungsi dengan baik.

+ +

6. Penyimpanan dan Retensi Data

+
    +
  • Data akun disimpan selama akun Anda aktif.
  • +
  • Anda dapat meminta penghapusan akun; setelah dihapus, data akan dihapus atau dianonimkan sesuai kebijakan retensi yang wajar.
  • +
+ +

7. Keamanan

+

Kami menerapkan langkah-langkah keamanan yang wajar, termasuk penggunaan penyedia terkemuka seperti Firebase. Namun, tidak ada metode transmisi atau penyimpanan yang sepenuhnya aman, sehingga kami tidak dapat menjamin keamanan absolut.

+ +

8. Hak Anda

+
    +
  • Mengakses dan memperbarui informasi akun.
  • +
  • Meminta penghapusan data tertentu atau penutupan akun.
  • +
  • Menarik persetujuan (mis. berhenti menggunakan Google Sign-In).
  • +
  • Menghubungi kami untuk pertanyaan terkait privasi.
  • +
+ +

9. Anak di Bawah Umur

+

Layanan tidak ditujukan untuk anak di bawah usia yang mewajibkan persetujuan orang tua menurut hukum yang berlaku. Jika Anda adalah orang tua/wali dan percaya anak Anda memberikan data kepada kami, silakan hubungi kami.

+ +

10. Transfer Internasional

+

Data dapat diproses di negara lain tempat penyedia layanan kami beroperasi (mis. infrastruktur Google). Kami akan mengambil langkah yang wajar untuk memastikan perlindungan yang sesuai.

+ +

11. Perubahan Kebijakan

+

Kami dapat memperbarui Kebijakan Privasi ini dari waktu ke waktu. Perubahan akan dipublikasikan di halaman ini dengan tanggal pembaruan terbaru.

+ +

12. Kontak

+

Untuk pertanyaan atau permintaan terkait data pribadi, hubungi: email kontak Anda di sini.

+ +

Lihat juga: Ketentuan Layanan

+
+ + \ No newline at end of file diff --git a/web/terms.html b/web/terms.html new file mode 100644 index 0000000..56c111c --- /dev/null +++ b/web/terms.html @@ -0,0 +1,71 @@ + + + + + + TList | Ketentuan Layanan + + + +
+

Ketentuan Layanan TList

+

Pembaruan terakhir: 16 Agustus 2025

+ +

Dengan mengakses atau menggunakan TList ("Layanan"), Anda menyatakan setuju untuk terikat pada Ketentuan Layanan ini. Jika Anda tidak setuju, mohon untuk tidak menggunakan Layanan.

+ +

1. Deskripsi Layanan

+

TList menyediakan aplikasi untuk manajemen akun dan autentikasi pengguna. Layanan ini menggunakan Firebase Authentication dan opsi masuk dengan Google.

+ +

2. Akun Pengguna

+
    +
  • Anda bertanggung jawab atas kerahasiaan kredensial akun Anda.
  • +
  • Anda harus memberikan informasi yang akurat dan memperbaruinya bila ada perubahan.
  • +
  • Anda setuju menerima email verifikasi dan komunikasi terkait keamanan akun (mis. reset sandi).
  • +
+ +

3. Privasi

+

Penggunaan data pribadi diatur dalam Kebijakan Privasi. Dengan menggunakan Layanan, Anda juga menyetujui Kebijakan Privasi tersebut.

+ +

4. Penggunaan yang Dilarang

+
    +
  • Melanggar hukum atau hak pihak lain.
  • +
  • Mengganggu keamanan atau integritas Layanan.
  • +
  • Mencoba mengakses area atau data yang tidak Anda berhak akses.
  • +
+ +

5. Kepemilikan dan Lisensi

+

Seluruh hak kekayaan intelektual atas Layanan dimiliki oleh pemilik TList. Anda diberi lisensi terbatas, non-eksklusif, dan dapat dibatalkan untuk menggunakan Layanan sesuai Ketentuan ini.

+ +

6. Layanan Pihak Ketiga

+

Layanan memanfaatkan penyedia pihak ketiga, termasuk Firebase Authentication (oleh Google) dan Google Sign-In. Penggunaan Anda juga tunduk pada ketentuan dan kebijakan mereka.

+ +

7. Pengakhiran

+

Kami dapat menangguhkan atau menghentikan akses Anda ke Layanan jika Anda melanggar Ketentuan ini atau menimbulkan risiko terhadap Layanan atau pengguna lain.

+ +

8. Penyangkalan Jaminan

+

Layanan disediakan "sebagaimana adanya" tanpa jaminan apa pun, tersurat maupun tersirat.

+ +

9. Batasan Tanggung Jawab

+

Sejauh diizinkan hukum yang berlaku, kami tidak bertanggung jawab atas kerugian tidak langsung, insidental, khusus, atau konsekuensial yang timbul dari penggunaan Layanan.

+ +

10. Perubahan Ketentuan

+

Kami dapat memperbarui Ketentuan ini dari waktu ke waktu. Versi terbaru akan ditampilkan di halaman ini.

+ +

11. Hukum yang Berlaku

+

Ketentuan ini diatur oleh hukum yang berlaku di Indonesia, tanpa mengesampingkan pertentangan kaidah hukum.

+ +

12. Kontak

+

Untuk pertanyaan, silakan hubungi kami di: email kontak Anda di sini.

+ +

Lihat juga: Kebijakan Privasi

+
+ + \ No newline at end of file diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index d141b74..a3c4d16 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,12 +6,18 @@ #include "generated_plugin_registrant.h" +#include #include #include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { + CloudFirestorePluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("CloudFirestorePluginCApi")); FirebaseAuthPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi")); FirebaseCorePluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 29944d5..c215e65 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,8 +3,10 @@ # list(APPEND FLUTTER_PLUGIN_LIST + cloud_firestore firebase_auth firebase_core + url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST