Gabung app Android Flutter 'Saat Invest' ke repo saat-app (folder flutter/)
Satu repo untuk backend Odoo (addon saat_invest) + app Android. - flutter/ berisi project Flutter (lib/, android/, pubspec, test) - Login Odoo JSON-RPC, tab Portofolio+Equity, tab Transaksi, catat jual/beli otomatis sync - Build .gitignore nested dipakai sehingga artefak build/.dart_tool tak ter-track Sejarah: gabung flat (satu sejarah backend+app).
This commit is contained in:
126
flutter/lib/add_transaksi_screen.dart
Normal file
126
flutter/lib/add_transaksi_screen.dart
Normal file
@@ -0,0 +1,126 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../models/saham_transaksi.dart';
|
||||
import '../services/stock_service.dart';
|
||||
|
||||
class AddTransaksiScreen extends StatefulWidget {
|
||||
const AddTransaksiScreen({super.key, required this.service});
|
||||
final StockService service;
|
||||
|
||||
@override
|
||||
State<AddTransaksiScreen> createState() => _AddTransaksiScreenState();
|
||||
}
|
||||
|
||||
class _AddTransaksiScreenState extends State<AddTransaksiScreen> {
|
||||
final _tanggal = TextEditingController(
|
||||
text: DateFormat('yyyy-MM-dd').format(DateTime.now()));
|
||||
final _volume = TextEditingController();
|
||||
final _harga = TextEditingController();
|
||||
final _fee = TextEditingController(text: '0');
|
||||
|
||||
late Future<List<Saham>> _sahamFut;
|
||||
int? _sahamId;
|
||||
bool _isBuy = true;
|
||||
bool _busy = false;
|
||||
String? _err;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_sahamFut = widget.service.listSaham();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Catat Transaksi')),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: ListView(
|
||||
children: [
|
||||
SegmentedButton<bool>(
|
||||
segments: const [
|
||||
ButtonSegment(value: true, label: Text('Beli'), icon: Icon(Icons.add)),
|
||||
ButtonSegment(value: false, label: Text('Jual'), icon: Icon(Icons.remove)),
|
||||
],
|
||||
selected: {_isBuy},
|
||||
onSelectionChanged: (s) => setState(() => _isBuy = s.first),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(controller: _tanggal,
|
||||
decoration: const InputDecoration(labelText: 'Tanggal (YYYY-MM-DD)',
|
||||
prefixIcon: Icon(Icons.calendar_today))),
|
||||
const SizedBox(height: 12),
|
||||
FutureBuilder<List<Saham>>(
|
||||
future: _sahamFut,
|
||||
builder: (context, snap) {
|
||||
if (snap.connectionState != ConnectionState.done) {
|
||||
return const ListTile(
|
||||
leading: SizedBox(width:18,height:18,child:CircularProgressIndicator(strokeWidth:2)),
|
||||
title: Text('Memuat saham...'));
|
||||
}
|
||||
if (snap.hasError) {
|
||||
return Text('Error saham: ${snap.error}', style: const TextStyle(color: Colors.red));
|
||||
}
|
||||
final saham = snap.data ?? [];
|
||||
return DropdownButtonFormField<int>(
|
||||
initialValue: _sahamId,
|
||||
decoration: const InputDecoration(labelText: 'Pilih Saham',
|
||||
prefixIcon: Icon(Icons.business)),
|
||||
items: [
|
||||
for (final s in saham)
|
||||
DropdownMenuItem(value: s.id, child: Text('${s.kode} — ${s.perusahaan ?? ''}')),
|
||||
],
|
||||
onChanged: (v) => setState(() => _sahamId = v),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(controller: _volume, keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(labelText: 'Jumlah Lembar', prefixIcon: Icon(Icons.numbers))),
|
||||
const SizedBox(height: 12),
|
||||
TextField(controller: _harga, keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
decoration: const InputDecoration(labelText: 'Harga per Lembar (Rp)', prefixIcon: Icon(Icons.attach_money))),
|
||||
const SizedBox(height: 12),
|
||||
TextField(controller: _fee, keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
decoration: const InputDecoration(labelText: 'Biaya / Fee (Rp)', prefixIcon: Icon(Icons.payments))),
|
||||
if (_err != null)
|
||||
Padding(padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(_err!, style: const TextStyle(color: Colors.red))),
|
||||
const SizedBox(height: 20),
|
||||
FilledButton.icon(
|
||||
onPressed: _busy ? null : _save,
|
||||
icon: _busy ? const SizedBox(width:18,height:18,child:CircularProgressIndicator(strokeWidth:2))
|
||||
: const Icon(Icons.save),
|
||||
label: const Text('Simpan'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
setState(() { _busy = true; _err = null; });
|
||||
try {
|
||||
final date = _tanggal.text.trim();
|
||||
final vol = int.tryParse(_volume.text.trim()) ?? 0;
|
||||
final harga = double.tryParse(_harga.text.replaceAll('.', '').replaceAll(',', '.')) ?? 0;
|
||||
final feeC = double.tryParse(_fee.text.trim().isEmpty ? '0' : _fee.text.trim()) ?? 0;
|
||||
if (_sahamId == null) throw Exception('Pilih saham dulu');
|
||||
if (vol <= 0) throw Exception('Volume harus > 0');
|
||||
if (harga <= 0) throw Exception('Harga harus > 0');
|
||||
if (!date.contains('-')) throw Exception('Format tanggal salah');
|
||||
|
||||
await widget.service.addTransaksi(
|
||||
sahamId: _sahamId!, tipe: _isBuy ? 'buy' : 'sell',
|
||||
volume: vol, harga: harga, fee: feeC, tanggal: date);
|
||||
if (!mounted) return;
|
||||
Navigator.pop(context, true);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() { _busy = false; _err = e.toString().replaceFirst('Exception: ', ''); });
|
||||
}
|
||||
}
|
||||
}
|
||||
77
flutter/lib/api/odoo_client.dart
Normal file
77
flutter/lib/api/odoo_client.dart
Normal file
@@ -0,0 +1,77 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
/// Klien JSON-RPC utk Odoo 17. Authenticate + execute_kw.
|
||||
class OdooClient {
|
||||
OdooClient({required this.baseUrl, required this.db, required this.username, required this.password})
|
||||
: _http = http.Client();
|
||||
|
||||
final String baseUrl; // mis: http://192.168.x.x:18069
|
||||
final String db; // nama db, default 'odoo'
|
||||
final String username;
|
||||
final String password;
|
||||
|
||||
final http.Client _http;
|
||||
int? _uid;
|
||||
|
||||
Uri get _endpoint => Uri.parse('$baseUrl/jsonrpc');
|
||||
|
||||
Future<int> authenticate() async {
|
||||
final result = await _call('common', 'authenticate', [db, username, password, {}]);
|
||||
if (result is int && result > 0) {
|
||||
_uid = result;
|
||||
return result;
|
||||
}
|
||||
throw Exception('Login gagal: periksa kredensial / URL Odoo');
|
||||
}
|
||||
|
||||
Future<dynamic> executeKwd(String model, String method,
|
||||
{List<dynamic> args = const [], Map<String, dynamic>? kwargs}) async {
|
||||
_uid ??= await authenticate();
|
||||
final params = [
|
||||
db,
|
||||
_uid,
|
||||
password,
|
||||
model,
|
||||
method,
|
||||
args,
|
||||
if (kwargs != null) kwargs,
|
||||
];
|
||||
return _call('object', 'execute_kw', params);
|
||||
}
|
||||
|
||||
Future<dynamic> _call(String service, String method, List<dynamic> args) async {
|
||||
final body = {
|
||||
'jsonrpc': '2.0',
|
||||
'method': 'call',
|
||||
'params': {
|
||||
'service': service,
|
||||
'method': method,
|
||||
'args': args,
|
||||
},
|
||||
};
|
||||
try {
|
||||
final resp = await _http
|
||||
.post(_endpoint,
|
||||
headers: {'Content-Type': 'application/json'}, body: jsonEncode(body))
|
||||
.timeout(const Duration(seconds: 25));
|
||||
final json = jsonDecode(resp.body) as Map<String, dynamic>;
|
||||
if (json.containsKey('error')) {
|
||||
final err = json['error'] as Map?;
|
||||
String? message = (err?['data'] as Map?)?['message'] as String? ??
|
||||
(err?['message'] as String?) ??
|
||||
'Unknown Odoo error';
|
||||
throw Exception('Odoo error: $message');
|
||||
}
|
||||
return json['result'];
|
||||
} on TimeoutException {
|
||||
throw Exception('Timeout — pastikan server Odoo:18069 tercapai');
|
||||
} on http.ClientException catch (e) {
|
||||
throw Exception('Koneksi gagal: ${e.message}');
|
||||
}
|
||||
}
|
||||
|
||||
void close() => _http.close();
|
||||
}
|
||||
299
flutter/lib/home_screen.dart
Normal file
299
flutter/lib/home_screen.dart
Normal file
@@ -0,0 +1,299 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api/odoo_client.dart';
|
||||
import '../models/portfolio.dart';
|
||||
import '../models/saham_transaksi.dart';
|
||||
import '../services/stock_service.dart';
|
||||
import 'add_transaksi_screen.dart';
|
||||
|
||||
class HomeScreen extends StatelessWidget {
|
||||
const HomeScreen({
|
||||
super.key,
|
||||
required this.baseUrl,
|
||||
required this.db,
|
||||
required this.user,
|
||||
required this.pass,
|
||||
required this.onLogout,
|
||||
});
|
||||
|
||||
final String baseUrl;
|
||||
final String db;
|
||||
final String user;
|
||||
final String pass;
|
||||
final VoidCallback onLogout;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final client = OdooClient(baseUrl: baseUrl, db: db, username: user, password: pass);
|
||||
final service = StockService(client);
|
||||
return _HomeShell(
|
||||
service: service,
|
||||
client: client,
|
||||
title: 'Portofolio',
|
||||
onLogout: onLogout,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HomeShell extends StatefulWidget {
|
||||
const _HomeShell({required this.service, required this.client, required this.title, required this.onLogout});
|
||||
final StockService service;
|
||||
final OdooClient client;
|
||||
final String title;
|
||||
final VoidCallback onLogout;
|
||||
|
||||
@override
|
||||
State<_HomeShell> createState() => _HomeShellState();
|
||||
}
|
||||
|
||||
class _HomeShellState extends State<_HomeShell> {
|
||||
int _tab = 0;
|
||||
int _reloadTick = 0;
|
||||
|
||||
void _reload() => setState(() => _reloadTick++);
|
||||
|
||||
Future<void> _openAdd() async {
|
||||
final added = await Navigator.of(context).push<bool>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => AddTransaksiScreen(service: widget.service),
|
||||
),
|
||||
);
|
||||
if (added == true) _reload();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Saat Invest'),
|
||||
actions: [
|
||||
IconButton(icon: const Icon(Icons.refresh), tooltip: 'Muat ulang',
|
||||
onPressed: _reload),
|
||||
IconButton(icon: const Icon(Icons.logout), tooltip: 'Keluar', onPressed: widget.onLogout),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: _openAdd,
|
||||
tooltip: 'Catat transaksi baru',
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
body: IndexedStack(
|
||||
index: _tab,
|
||||
children: [
|
||||
_PortfolioTab(key: ValueKey('pf$_reloadTick'), service: widget.service),
|
||||
_TransaksiTab(key: ValueKey('tx$_reloadTick'), service: widget.service),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: _tab,
|
||||
onDestinationSelected: (i) => setState(() => _tab = i),
|
||||
destinations: const [
|
||||
NavigationDestination(icon: Icon(Icons.pie_chart), label: 'Portofolio'),
|
||||
NavigationDestination(icon: Icon(Icons.receipt_long), label: 'Transaksi'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- Portofolio / Equity tab ----------------
|
||||
|
||||
class _PortfolioTab extends StatefulWidget {
|
||||
const _PortfolioTab({super.key, required this.service});
|
||||
final StockService service;
|
||||
@override
|
||||
State<_PortfolioTab> createState() => _PortfolioTabState();
|
||||
}
|
||||
|
||||
class _PortfolioTabState extends State<_PortfolioTab> {
|
||||
late Future<List<Portfolio>> _fut;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_fut = widget.service.listPortofolio();
|
||||
}
|
||||
|
||||
void _reload() => setState(() => _fut = widget.service.listPortofolio());
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<List<Portfolio>>(
|
||||
future: _fut,
|
||||
builder: (context, snap) {
|
||||
if (snap.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snap.hasError) {
|
||||
return _ErrorRetry(message: '${snap.error}', onRetry: _reload);
|
||||
}
|
||||
final list = snap.data ?? [];
|
||||
final totalNilai = list.fold<double>(
|
||||
0, (a, p) => a + p.nilaiSekarang);
|
||||
final totalModal = list.fold<double>(0, (a, p) => a + p.avgCost * p.sisaLembar);
|
||||
final totalPl = list.fold<double>(0, (a, p) => a + p.unrealizedPl + p.realizedPl);
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async => _reload(),
|
||||
child: ListView(
|
||||
children: [
|
||||
_EquityCard(equity: totalNilai, modal: totalModal, pl: totalPl),
|
||||
if (list.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: Text('Belum ada posisi.\nCatat transaksi beli dulu di tab Transaksi.',
|
||||
textAlign: TextAlign.center),
|
||||
),
|
||||
...list.map((p) => Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: ListTile(
|
||||
title: Text(p.sahamTicker, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
subtitle: Text('${p.sisaLembar} lbr · avg ${_fmt(p.avgCost)} · hrga ${_fmt(p.hargaSekarang)}'),
|
||||
trailing: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(_fmt(p.nilaiSekarang)),
|
||||
Text(_rm(p.unrealizedPl), style: TextStyle(
|
||||
color: p.unrealizedPl >= 0 ? Colors.green : Colors.red)),
|
||||
],
|
||||
),
|
||||
),
|
||||
)),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EquityCard extends StatelessWidget {
|
||||
const _EquityCard({required this.equity, required this.modal, required this.pl});
|
||||
final double equity;
|
||||
final double modal;
|
||||
final double pl;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pct = modal > 0 ? (pl / modal) * 100 : 0.0;
|
||||
return Card(
|
||||
margin: const EdgeInsets.all(12),
|
||||
color: const Color(0xFF0B6E4F),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text('Total Equity', style: TextStyle(color: Colors.white70, fontSize: 14)),
|
||||
Text('Rp ${_fmt(equity, force: true)}',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 28, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 8),
|
||||
Text('Modal dikeluarkan: ${_fmt(modal)}',
|
||||
style: const TextStyle(color: Colors.white70)),
|
||||
Row(children: [
|
||||
Text('P/L: ', style: const TextStyle(color: Colors.white70)),
|
||||
Text(_rm(pl),
|
||||
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(width: 8),
|
||||
Text('(${pct.toStringAsFixed(2)}%)', style: const TextStyle(color: Colors.white70)),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- Transaksi tab ----------------
|
||||
|
||||
class _TransaksiTab extends StatefulWidget {
|
||||
const _TransaksiTab({super.key, required this.service});
|
||||
final StockService service;
|
||||
@override
|
||||
State<_TransaksiTab> createState() => _TransaksiTabState();
|
||||
}
|
||||
|
||||
class _TransaksiTabState extends State<_TransaksiTab> {
|
||||
late Future<List<Transaksi>> _fut;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_fut = widget.service.listTransaksi();
|
||||
}
|
||||
|
||||
void _reload() => setState(() => _fut = widget.service.listTransaksi());
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<List<Transaksi>>(
|
||||
future: _fut,
|
||||
builder: (context, snap) {
|
||||
if (snap.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snap.hasError) {
|
||||
return _ErrorRetry(message: '${snap.error}', onRetry: _reload);
|
||||
}
|
||||
final list = snap.data ?? [];
|
||||
return Column(children: [
|
||||
Expanded(
|
||||
child: list.isEmpty
|
||||
? const Center(child: Text('Belum ada transaksi.'))
|
||||
: RefreshIndicator(
|
||||
onRefresh: () async => _reload(),
|
||||
child: ListView.builder(
|
||||
itemCount: list.length,
|
||||
itemBuilder: (c, i) {
|
||||
final t = list[i];
|
||||
final buy = t.isBuy;
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: buy ? Colors.green : Colors.red,
|
||||
child: Text(buy ? 'B' : 'J', style: const TextStyle(color: Colors.white)),
|
||||
),
|
||||
title: Text('${t.sahamTicker} · ${t.volume} lbr'),
|
||||
subtitle: Text('${t.tanggal} @ ${_fmt(t.harga)} · fee ${_fmt(t.fee)}'),
|
||||
trailing: Text(_fmt(t.total)),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
]);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ErrorRetry extends StatelessWidget {
|
||||
const _ErrorRetry({required this.message, required this.onRetry});
|
||||
final String message;
|
||||
final VoidCallback onRetry;
|
||||
@override
|
||||
Widget build(BuildContext context) => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
const Icon(Icons.error_outline, color: Colors.red, size: 40),
|
||||
const SizedBox(height: 8),
|
||||
Text(message, textAlign: TextAlign.center),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton(onPressed: onRetry, child: const Text('Coba lagi')),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// helpers
|
||||
String _fmt(double v, {bool force = false}) {
|
||||
final fmt = force
|
||||
? v.toStringAsFixed(0).replaceAllMapped(RegExp(r'\B(?=(\d{3})+(?!\d))'), (m) => '.')
|
||||
: v.toStringAsFixed(0).replaceAllMapped(RegExp(r'\B(?=(\d{3})+(?!\d))'), (m) => '.');
|
||||
return fmt;
|
||||
}
|
||||
|
||||
String _rm(double v) =>
|
||||
'${v >= 0 ? '+' : ''}Rp ${_formatAbs(v)}';
|
||||
String _formatAbs(double v) {
|
||||
final a = v.abs().toStringAsFixed(0);
|
||||
return a.replaceAllMapped(RegExp(r'\B(?=(\d{3})+(?!\d))'), (m) => '.');
|
||||
}
|
||||
179
flutter/lib/main.dart
Normal file
179
flutter/lib/main.dart
Normal file
@@ -0,0 +1,179 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api/odoo_client.dart';
|
||||
import '../services/session.dart';
|
||||
import 'home_screen.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
runApp(const SaatInvestApp());
|
||||
}
|
||||
|
||||
class SaatInvestApp extends StatelessWidget {
|
||||
const SaatInvestApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Saat Invest',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: ThemeData(colorSchemeSeed: const Color(0xFF0B6E4F), useMaterial3: true),
|
||||
home: const RootScreen(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Jika session tersimpan -> Home, jika tidak -> Login.
|
||||
class RootScreen extends StatefulWidget {
|
||||
const RootScreen({super.key});
|
||||
|
||||
@override
|
||||
State<RootScreen> createState() => _RootScreenState();
|
||||
}
|
||||
|
||||
class _RootScreenState extends State<RootScreen> {
|
||||
late Future<({String base, String db, String user, String pass})?> _check;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_check = Session.load();
|
||||
}
|
||||
|
||||
void _onLoginDone({required String base, required String db, required String user, required String pass}) {
|
||||
setState(() {
|
||||
_check = Future.value(
|
||||
(base: base, db: db, user: user, pass: pass),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _logout() async {
|
||||
await Session.clear();
|
||||
setState(() => _check = Session.load());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder(
|
||||
future: _check,
|
||||
builder: (context, snap) {
|
||||
if (snap.connectionState != ConnectionState.done) {
|
||||
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||
}
|
||||
final s = snap.data;
|
||||
if (s == null) {
|
||||
return LoginScreen(onSuccess: _onLoginDone);
|
||||
}
|
||||
return HomeScreen(
|
||||
baseUrl: s.base,
|
||||
db: s.db,
|
||||
user: s.user,
|
||||
pass: s.pass,
|
||||
onLogout: _logout,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class LoginScreen extends StatefulWidget {
|
||||
const LoginScreen({super.key, required this.onSuccess});
|
||||
|
||||
final void Function({required String base, required String db, required String user, required String pass}) onSuccess;
|
||||
|
||||
@override
|
||||
State<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends State<LoginScreen> {
|
||||
final _url = TextEditingController(); // mis http://<IP>:18069
|
||||
final _db = TextEditingController(text: 'odoo');
|
||||
final _user = TextEditingController(text: 'admin');
|
||||
final _pass = TextEditingController();
|
||||
bool _busy = false;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF0B6E4F),
|
||||
body: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Card(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Icon(Icons.trending_up, color: Color(0xFF0B6E4F), size: 40),
|
||||
const SizedBox(height: 8),
|
||||
Text('Saat Invest', textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.headlineSmall),
|
||||
Text('Catat Jual-Beli Saham via Odoo',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodySmall),
|
||||
const SizedBox(height: 20),
|
||||
TextField(controller: _url,
|
||||
decoration: const InputDecoration(labelText: 'URL Server Odoo',
|
||||
hintText: 'http://192.168.1.5:18069',
|
||||
prefixIcon: Icon(Icons.dns))),
|
||||
const SizedBox(height: 12),
|
||||
TextField(controller: _db,
|
||||
decoration: const InputDecoration(labelText: 'Database', prefixIcon: Icon(Icons.storage))),
|
||||
const SizedBox(height: 12),
|
||||
TextField(controller: _user,
|
||||
decoration: const InputDecoration(labelText: 'Username', prefixIcon: Icon(Icons.person))),
|
||||
const SizedBox(height: 12),
|
||||
TextField(controller: _pass, obscureText: true,
|
||||
decoration: const InputDecoration(labelText: 'Password', prefixIcon: Icon(Icons.lock))),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(_error!, style: const TextStyle(color: Colors.red)),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
FilledButton.icon(
|
||||
onPressed: _busy ? null : _login,
|
||||
icon: _busy
|
||||
? const SizedBox(width: 18, height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Icon(Icons.login),
|
||||
label: const Text('Masuk'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _login() async {
|
||||
setState(() { _busy = true; _error = null; });
|
||||
String base = _url.text.trim();
|
||||
if (base.isNotEmpty && !base.startsWith('http')) base = 'http://$base';
|
||||
try {
|
||||
final c = OdooClient(
|
||||
baseUrl: base, db: _db.text.trim(), username: _user.text.trim(),
|
||||
password: _pass.text,
|
||||
);
|
||||
await c.authenticate(); // hit; lempar bila gagal
|
||||
await Session.save(base, _db.text.trim(), _user.text.trim(), _pass.text);
|
||||
if (!mounted) return;
|
||||
widget.onSuccess(base: base, db: _db.text.trim(), user: _user.text.trim(), pass: _pass.text);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_busy = false;
|
||||
_error = e.toString().replaceFirst('Exception: ', '');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
41
flutter/lib/models/portfolio.dart
Normal file
41
flutter/lib/models/portfolio.dart
Normal file
@@ -0,0 +1,41 @@
|
||||
/// Model Portofolio: ringkasan posisi per saham dari Odoo saat.portofolio.
|
||||
class Portfolio {
|
||||
final int id;
|
||||
final String sahamTicker;
|
||||
final int sisaLembar;
|
||||
final double avgCost;
|
||||
final double hargaSekarang;
|
||||
final double nilaiSekarang;
|
||||
final double realizedPl;
|
||||
final double unrealizedPl;
|
||||
|
||||
Portfolio({
|
||||
required this.id,
|
||||
required this.sahamTicker,
|
||||
required this.sisaLembar,
|
||||
required this.avgCost,
|
||||
required this.hargaSekarang,
|
||||
required this.nilaiSekarang,
|
||||
required this.realizedPl,
|
||||
required this.unrealizedPl,
|
||||
});
|
||||
|
||||
factory Portfolio.fromOdoo(Map<String, dynamic> m) {
|
||||
// saham_id adalah tuple/list [id, ticker] dalam search_read
|
||||
String ticker = '';
|
||||
final sahamId = m['saham_id'];
|
||||
if (sahamId is List && sahamId.isNotEmpty) {
|
||||
ticker = sahamId.length > 1 ? sahamId[1].toString() : '${sahamId[0]}';
|
||||
}
|
||||
return Portfolio(
|
||||
id: (m['id'] as num).toInt(),
|
||||
sahamTicker: ticker,
|
||||
sisaLembar: (m['sisa_lembar'] as num?)?.toInt() ?? 0,
|
||||
avgCost: (m['avg_cost'] as num?)?.toDouble() ?? 0,
|
||||
hargaSekarang: (m['harga_sekarang'] as num?)?.toDouble() ?? 0,
|
||||
nilaiSekarang: (m['nilai_sekarang'] as num?)?.toDouble() ?? 0,
|
||||
realizedPl: (m['realized_pl'] as num?)?.toDouble() ?? 0,
|
||||
unrealizedPl: (m['unrealized_pl'] as num?)?.toDouble() ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
63
flutter/lib/models/saham_transaksi.dart
Normal file
63
flutter/lib/models/saham_transaksi.dart
Normal file
@@ -0,0 +1,63 @@
|
||||
/// Model Saham master dari saat.saham.
|
||||
class Saham {
|
||||
final int id;
|
||||
final String kode;
|
||||
final String? perusahaan;
|
||||
final String? sektor;
|
||||
|
||||
Saham({required this.id, required this.kode, this.perusahaan, this.sektor});
|
||||
|
||||
factory Saham.fromOdoo(Map<String, dynamic> m) => Saham(
|
||||
id: (m['id'] as num).toInt(),
|
||||
kode: (m['name'] as String?) ?? '',
|
||||
perusahaan: m['perusahaan'] as String?,
|
||||
sektor: m['sector'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
/// Model Transaksi dari saat.transaksi.
|
||||
class Transaksi {
|
||||
final int id;
|
||||
final String tanggal;
|
||||
final String tipe; // buy | sell
|
||||
final int sahamId;
|
||||
final String sahamTicker;
|
||||
final int volume;
|
||||
final double harga;
|
||||
final double fee;
|
||||
final double total;
|
||||
|
||||
Transaksi({
|
||||
required this.id,
|
||||
required this.tanggal,
|
||||
required this.tipe,
|
||||
required this.sahamId,
|
||||
required this.sahamTicker,
|
||||
required this.volume,
|
||||
required this.harga,
|
||||
required this.fee,
|
||||
required this.total,
|
||||
});
|
||||
|
||||
bool get isBuy => tipe == 'buy';
|
||||
|
||||
factory Transaksi.fromOdoo(Map<String, dynamic> m) {
|
||||
String ticker = '';
|
||||
final s = m['saham_id'];
|
||||
if (s is List && s.isNotEmpty) {
|
||||
ticker = s.length > 1 ? s[1].toString() : '${s[0]}';
|
||||
}
|
||||
final sid = s is List ? (s.first as num).toInt() : (s as num?)?.toInt() ?? 0;
|
||||
return Transaksi(
|
||||
id: (m['id'] as num).toInt(),
|
||||
tanggal: (m['tanggal'] as String?) ?? '',
|
||||
tipe: (m['tipe'] as String?) ?? 'buy',
|
||||
sahamId: sid,
|
||||
sahamTicker: ticker,
|
||||
volume: (m['volume'] as num?)?.toInt() ?? 0,
|
||||
harga: (m['harga'] as num?)?.toDouble() ?? 0,
|
||||
fee: (m['fee'] as num?)?.toDouble() ?? 0,
|
||||
total: (m['total'] as num?)?.toDouble() ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
35
flutter/lib/services/session.dart
Normal file
35
flutter/lib/services/session.dart
Normal file
@@ -0,0 +1,35 @@
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
const _kBase = 'odoo_base';
|
||||
const _kDb = 'odoo_db';
|
||||
const _kUser = 'odoo_user';
|
||||
const _kPass = 'odoo_pass';
|
||||
|
||||
/// Menyimpan konfigurasi login (url, db, user, password) secara aman
|
||||
/// + menyediakan OdooClient terautentikasi.
|
||||
class Session {
|
||||
static const _storage = FlutterSecureStorage();
|
||||
|
||||
static Future<void> save(String baseUrl, String db, String user, String pass) async {
|
||||
await _storage.write(key: _kBase, value: baseUrl);
|
||||
await _storage.write(key: _kDb, value: db);
|
||||
await _storage.write(key: _kUser, value: user);
|
||||
await _storage.write(key: _kPass, value: pass);
|
||||
}
|
||||
|
||||
static Future<({String base, String db, String user, String pass})?> load() async {
|
||||
final base = await _storage.read(key: _kBase);
|
||||
final db = await _storage.read(key: _kDb);
|
||||
final user = await _storage.read(key: _kUser);
|
||||
final pass = await _storage.read(key: _kPass);
|
||||
if (base == null || user == null || pass == null) return null;
|
||||
return (base: base, db: db ?? 'odoo', user: user, pass: pass);
|
||||
}
|
||||
|
||||
static Future<void> clear() async {
|
||||
await _storage.delete(key: _kBase);
|
||||
await _storage.delete(key: _kDb);
|
||||
await _storage.delete(key: _kUser);
|
||||
await _storage.delete(key: _kPass);
|
||||
}
|
||||
}
|
||||
83
flutter/lib/services/stock_service.dart
Normal file
83
flutter/lib/services/stock_service.dart
Normal file
@@ -0,0 +1,83 @@
|
||||
import '../api/odoo_client.dart';
|
||||
import '../models/portfolio.dart';
|
||||
import '../models/saham_transaksi.dart';
|
||||
|
||||
/// Layanan domain utk model Odoo saat_invest.
|
||||
class StockService {
|
||||
StockService(this.client);
|
||||
|
||||
final OdooClient client;
|
||||
|
||||
// ---- Saham ----
|
||||
Future<List<Saham>> listSaham() async {
|
||||
final res = await client.executeKwd(
|
||||
'saat.saham',
|
||||
'search_read',
|
||||
args: [
|
||||
[],
|
||||
{'fields': ['name', 'perusahaan', 'sector']},
|
||||
],
|
||||
);
|
||||
return (res as List).map((e) => Saham.fromOdoo(e as Map<String, dynamic>)).toList();
|
||||
}
|
||||
|
||||
// ---- Portofolio ----
|
||||
Future<List<Portfolio>> listPortofolio() async {
|
||||
final res = await client.executeKwd(
|
||||
'saat.portofolio',
|
||||
'search_read',
|
||||
args: [
|
||||
[],
|
||||
{
|
||||
'fields': [
|
||||
'saham_id', 'sisa_lembar', 'avg_cost', 'harga_sekarang',
|
||||
'nilai_sekarang', 'realized_pl', 'unrealized_pl',
|
||||
]
|
||||
},
|
||||
],
|
||||
);
|
||||
return (res as List).map((e) => Portfolio.fromOdoo(e as Map<String, dynamic>)).toList();
|
||||
}
|
||||
|
||||
// ---- Transaksi ----
|
||||
Future<List<Transaksi>> listTransaksi() async {
|
||||
final res = await client.executeKwd(
|
||||
'saat.transaksi',
|
||||
'search_read',
|
||||
args: [
|
||||
[],
|
||||
{
|
||||
'fields': [
|
||||
'tanggal', 'tipe', 'saham_id', 'volume', 'harga', 'fee', 'total'
|
||||
],
|
||||
'order': 'tanggal desc, id desc',
|
||||
},
|
||||
],
|
||||
);
|
||||
return (res as List).map((e) => Transaksi.fromOdoo(e as Map<String, dynamic>)).toList();
|
||||
}
|
||||
|
||||
Future<int> addTransaksi({
|
||||
required int sahamId,
|
||||
required String tipe, // buy | sell
|
||||
required int volume,
|
||||
required double harga,
|
||||
required double fee,
|
||||
String? tanggal, // 'YYYY-MM-DD'
|
||||
}) async {
|
||||
final vals = <String, dynamic>{
|
||||
'saham_id': sahamId,
|
||||
'tipe': tipe,
|
||||
'volume': volume,
|
||||
'harga': harga,
|
||||
'fee': fee,
|
||||
if (tanggal != null) 'tanggal': tanggal,
|
||||
};
|
||||
final res = await client.executeKwd('saat.transaksi', 'create', args: [vals]);
|
||||
return (res as num).toInt();
|
||||
}
|
||||
|
||||
Future<void> hapusTransaksi(List<int> ids) async {
|
||||
await client.executeKwd('saat.transaksi', 'unlink', args: [ids]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user