Flutter: fitur Tambah Saham (tab Saham + form kode/perusahaan/sektor)

- Tab baru 'Saham' di NavigationBar (daftar master saham dari saat.saham)
- FAB konteks: di tab Saham buka form Tambah Saham, di tab lain catat transaksi
- EditSahamScreen: form tambah saham baru (kode ticker, perusahaan, sektor)
- StockService: method addSaham(), updateSaham(), hapusSaham() ke Odoo
This commit is contained in:
2026-09-06 19:52:07 +07:00
parent 286b207f27
commit 1fc095c63c
3 changed files with 290 additions and 63 deletions

View File

@@ -0,0 +1,115 @@
import 'package:flutter/material.dart';
import '../models/saham_transaksi.dart';
import '../services/stock_service.dart';
class EditSahamScreen extends StatefulWidget {
const EditSahamScreen({super.key, required this.service, this.saham});
final StockService service;
final Saham? saham; // null = tambah baru; ada = edit
@override
State<EditSahamScreen> createState() => _EditSahamScreenState();
}
class _EditSahamScreenState extends State<EditSahamScreen> {
final _kode = TextEditingController();
final _perusahaan = TextEditingController();
final _sektor = TextEditingController();
bool _busy = false;
String? _error;
@override
void initState() {
super.initState();
final s = widget.saham;
if (s != null) {
_kode.text = s.kode;
_perusahaan.text = s.perusahaan ?? '';
_sektor.text = s.sektor ?? '';
} else {
_sektor.text = 'Financial';
}
}
@override
Widget build(BuildContext context) {
final isEdit = widget.saham != null;
return Scaffold(
appBar: AppBar(title: Text(isEdit ? 'Edit Saham' : 'Tambah Saham')),
body: Padding(
padding: const EdgeInsets.all(16),
child: ListView(
children: [
TextField(
controller: _kode,
enabled: !isEdit, // kode tidak boleh diubah saat edit
decoration: const InputDecoration(
labelText: 'Kode Saham (Ticker)',
hintText: 'misal: BBRI, UNVR, TLKM',
prefixIcon: Icon(Icons.business)),
),
const SizedBox(height: 12),
TextField(
controller: _perusahaan,
decoration: const InputDecoration(
labelText: 'Nama Perusahaan / Emiten',
prefixIcon: Icon(Icons.apartment)),
),
const SizedBox(height: 12),
TextField(
controller: _sektor,
decoration: const InputDecoration(
labelText: 'Sektor', prefixIcon: Icon(Icons.category)),
),
if (_error != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(_error!, 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;
_error = null;
});
try {
final kode = _kode.text.trim().toUpperCase();
if (kode.isEmpty) throw Exception('Kode saham wajib diisi');
if (widget.saham == null) {
await widget.service.addSaham(
kode: kode,
perusahaan: _perusahaan.text.trim(),
sektor: _sektor.text.trim(),
);
} else {
await widget.service.updateSaham(widget.saham!.id,
perusahaan: _perusahaan.text.trim(),
sektor: _sektor.text.trim(),
);
}
if (!mounted) return;
Navigator.pop(context, true);
} catch (e) {
if (!mounted) return;
setState(() {
_busy = false;
_error = e.toString().replaceFirst('Exception: ', '');
});
}
}
}

View File

@@ -5,6 +5,7 @@ import '../models/portfolio.dart';
import '../models/saham_transaksi.dart'; import '../models/saham_transaksi.dart';
import '../services/stock_service.dart'; import '../services/stock_service.dart';
import 'add_transaksi_screen.dart'; import 'add_transaksi_screen.dart';
import 'edit_saham_screen.dart';
class HomeScreen extends StatelessWidget { class HomeScreen extends StatelessWidget {
const HomeScreen({ const HomeScreen({
@@ -24,7 +25,8 @@ class HomeScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final client = OdooClient(baseUrl: baseUrl, db: db, username: user, password: pass); final client = OdooClient(
baseUrl: baseUrl, db: db, username: user, password: pass);
final service = StockService(client); final service = StockService(client);
return _HomeShell( return _HomeShell(
service: service, service: service,
@@ -53,12 +55,23 @@ class _HomeShellState extends State<_HomeShell> {
void _reload() => setState(() => _reloadTick++); void _reload() => setState(() => _reloadTick++);
Future<void> _openAdd() async { Future<void> _openAdd() async {
final added = await Navigator.of(context).push<bool>( if (_tab == 1) {
MaterialPageRoute( // Tab Saham -> buka tambah saham
builder: (_) => AddTransaksiScreen(service: widget.service), final added = await Navigator.of(context).push<bool>(
), MaterialPageRoute(
); builder: (_) => EditSahamScreen(service: widget.service),
if (added == true) _reload(); ),
);
if (added == true) _reload();
} else {
// Tab lain -> buka buat transaksi
final added = await Navigator.of(context).push<bool>(
MaterialPageRoute(
builder: (_) => AddTransaksiScreen(service: widget.service),
),
);
if (added == true) _reload();
}
} }
@override @override
@@ -67,20 +80,26 @@ class _HomeShellState extends State<_HomeShell> {
appBar: AppBar( appBar: AppBar(
title: const Text('Saat Invest'), title: const Text('Saat Invest'),
actions: [ actions: [
IconButton(icon: const Icon(Icons.refresh), tooltip: 'Muat ulang', IconButton(
icon: const Icon(Icons.refresh),
tooltip: 'Muat ulang',
onPressed: _reload), onPressed: _reload),
IconButton(icon: const Icon(Icons.logout), tooltip: 'Keluar', onPressed: widget.onLogout), IconButton(
icon: const Icon(Icons.logout),
tooltip: 'Keluar',
onPressed: widget.onLogout),
], ],
), ),
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(
onPressed: _openAdd, onPressed: _openAdd,
tooltip: 'Catat transaksi baru', tooltip: _tab == 1 ? 'Tambah Saham' : 'Catat Transaksi',
child: const Icon(Icons.add), child: Icon(_tab == 1 ? Icons.add_business : Icons.add),
), ),
body: IndexedStack( body: IndexedStack(
index: _tab, index: _tab,
children: [ children: [
_PortfolioTab(key: ValueKey('pf$_reloadTick'), service: widget.service), _PortfolioTab(key: ValueKey('pf$_reloadTick'), service: widget.service),
_SahamTab(key: ValueKey('sh$_reloadTick'), service: widget.service),
_TransaksiTab(key: ValueKey('tx$_reloadTick'), service: widget.service), _TransaksiTab(key: ValueKey('tx$_reloadTick'), service: widget.service),
], ],
), ),
@@ -89,6 +108,7 @@ class _HomeShellState extends State<_HomeShell> {
onDestinationSelected: (i) => setState(() => _tab = i), onDestinationSelected: (i) => setState(() => _tab = i),
destinations: const [ destinations: const [
NavigationDestination(icon: Icon(Icons.pie_chart), label: 'Portofolio'), NavigationDestination(icon: Icon(Icons.pie_chart), label: 'Portofolio'),
NavigationDestination(icon: Icon(Icons.trending_up), label: 'Saham'),
NavigationDestination(icon: Icon(Icons.receipt_long), label: 'Transaksi'), NavigationDestination(icon: Icon(Icons.receipt_long), label: 'Transaksi'),
], ],
), ),
@@ -96,7 +116,7 @@ class _HomeShellState extends State<_HomeShell> {
} }
} }
// ---------------- Portofolio / Equity tab ---------------- // ---------------- Portofolio tab ----------------
class _PortfolioTab extends StatefulWidget { class _PortfolioTab extends StatefulWidget {
const _PortfolioTab({super.key, required this.service}); const _PortfolioTab({super.key, required this.service});
@@ -107,7 +127,6 @@ class _PortfolioTab extends StatefulWidget {
class _PortfolioTabState extends State<_PortfolioTab> { class _PortfolioTabState extends State<_PortfolioTab> {
late Future<List<Portfolio>> _fut; late Future<List<Portfolio>> _fut;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -128,8 +147,7 @@ class _PortfolioTabState extends State<_PortfolioTab> {
return _ErrorRetry(message: '${snap.error}', onRetry: _reload); return _ErrorRetry(message: '${snap.error}', onRetry: _reload);
} }
final list = snap.data ?? []; final list = snap.data ?? [];
final totalNilai = list.fold<double>( final totalNilai = list.fold<double>(0, (a, p) => a + p.nilaiSekarang);
0, (a, p) => a + p.nilaiSekarang);
final totalModal = list.fold<double>(0, (a, p) => a + p.avgCost * p.sisaLembar); 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); final totalPl = list.fold<double>(0, (a, p) => a + p.unrealizedPl + p.realizedPl);
return RefreshIndicator( return RefreshIndicator(
@@ -144,21 +162,24 @@ class _PortfolioTabState extends State<_PortfolioTab> {
textAlign: TextAlign.center), textAlign: TextAlign.center),
), ),
...list.map((p) => Card( ...list.map((p) => Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: ListTile( child: ListTile(
title: Text(p.sahamTicker, style: const TextStyle(fontWeight: FontWeight.bold)), title: Text(p.sahamTicker,
subtitle: Text('${p.sisaLembar} lbr · avg ${_fmt(p.avgCost)} · hrga ${_fmt(p.hargaSekarang)}'), style: const TextStyle(fontWeight: FontWeight.bold)),
trailing: Column( subtitle: Text(
crossAxisAlignment: CrossAxisAlignment.end, '${p.sisaLembar} lbr · avg ${_fmt(p.avgCost)} · hrga ${_fmt(p.hargaSekarang)}'),
mainAxisAlignment: MainAxisAlignment.center, trailing: Column(
children: [ crossAxisAlignment: CrossAxisAlignment.end,
Text(_fmt(p.nilaiSekarang)), mainAxisAlignment: MainAxisAlignment.center,
Text(_rm(p.unrealizedPl), style: TextStyle( children: [
color: p.unrealizedPl >= 0 ? Colors.green : Colors.red)), Text(_fmt(p.nilaiSekarang)),
], Text(_rm(p.unrealizedPl),
), style: TextStyle(
), color: p.unrealizedPl >= 0 ? Colors.green : Colors.red)),
)), ],
),
),
)),
const SizedBox(height: 16), const SizedBox(height: 16),
], ],
), ),
@@ -181,23 +202,85 @@ class _EquityCard extends StatelessWidget {
margin: const EdgeInsets.all(12), margin: const EdgeInsets.all(12),
color: const Color(0xFF0B6E4F), color: const Color(0xFF0B6E4F),
child: Padding( child: Padding(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('Total Equity', style: TextStyle(color: Colors.white70, fontSize: 14)), Text('Total Equity',
Text('Rp ${_fmt(equity, force: true)}', style: TextStyle(color: Colors.white70, fontSize: 14)),
style: const TextStyle(color: Colors.white, fontSize: 28, fontWeight: FontWeight.bold)), Text('Rp ${_fmt(equity, force: true)}',
const SizedBox(height: 8), style: const TextStyle(
Text('Modal dikeluarkan: ${_fmt(modal)}', color: Colors.white,
style: const TextStyle(color: Colors.white70)), fontSize: 28,
Row(children: [ fontWeight: FontWeight.bold)),
Text('P/L: ', style: const TextStyle(color: Colors.white70)), const SizedBox(height: 8),
Text(_rm(pl), Text('Modal dikeluarkan: ${_fmt(modal)}',
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600)), style: const TextStyle(color: Colors.white70)),
const SizedBox(width: 8), Row(children: [
Text('(${pct.toStringAsFixed(2)}%)', style: const TextStyle(color: Colors.white70)), 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)),
]),
])),
);
}
}
// ---------------- Saham tab ----------------
class _SahamTab extends StatefulWidget {
const _SahamTab({super.key, required this.service});
final StockService service;
@override
State<_SahamTab> createState() => _SahamTabState();
}
class _SahamTabState extends State<_SahamTab> {
late Future<List<Saham>> _fut;
@override
void initState() {
super.initState();
_fut = widget.service.listSaham();
}
void _reload() => setState(() => _fut = widget.service.listSaham());
@override
Widget build(BuildContext context) {
return FutureBuilder<List<Saham>>(
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 RefreshIndicator(
onRefresh: () async => _reload(),
child: ListView.builder(
itemCount: list.length,
itemBuilder: (c, i) {
final s = list[i];
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: ListTile(
leading: CircleAvatar(
backgroundColor: const Color(0xFF0B6E4F),
child: Text(s.kode.substring(0, 1).toUpperCase(),
style: const TextStyle(color: Colors.white)),
),
title: Text('${s.kode} — ${s.perusahaan ?? '-'}'),
subtitle: Text(s.sektor ?? 'Sektor: -'),
),
);
},
),
);
},
); );
} }
} }
@@ -213,7 +296,6 @@ class _TransaksiTab extends StatefulWidget {
class _TransaksiTabState extends State<_TransaksiTab> { class _TransaksiTabState extends State<_TransaksiTab> {
late Future<List<Transaksi>> _fut; late Future<List<Transaksi>> _fut;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -247,9 +329,10 @@ class _TransaksiTabState extends State<_TransaksiTab> {
final buy = t.isBuy; final buy = t.isBuy;
return ListTile( return ListTile(
leading: CircleAvatar( leading: CircleAvatar(
backgroundColor: buy ? Colors.green : Colors.red, backgroundColor:
child: Text(buy ? 'B' : 'J', style: const TextStyle(color: Colors.white)), buy ? Colors.green : Colors.red,
), child: Text(buy ? 'B' : 'J',
style: const TextStyle(color: Colors.white))),
title: Text('${t.sahamTicker} · ${t.volume} lbr'), title: Text('${t.sahamTicker} · ${t.volume} lbr'),
subtitle: Text('${t.tanggal} @ ${_fmt(t.harga)} · fee ${_fmt(t.fee)}'), subtitle: Text('${t.tanggal} @ ${_fmt(t.harga)} · fee ${_fmt(t.fee)}'),
trailing: Text(_fmt(t.total)), trailing: Text(_fmt(t.total)),
@@ -271,23 +354,24 @@ class _ErrorRetry extends StatelessWidget {
@override @override
Widget build(BuildContext context) => Center( Widget build(BuildContext context) => Center(
child: Padding( child: Padding(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: Column(mainAxisSize: MainAxisSize.min, children: [ child: Column(mainAxisSize: MainAxisSize.min, children: [
const Icon(Icons.error_outline, color: Colors.red, size: 40), const Icon(Icons.error_outline, color: Colors.red, size: 40),
const SizedBox(height: 8), const SizedBox(height: 8),
Text(message, textAlign: TextAlign.center), Text(message, textAlign: TextAlign.center),
const SizedBox(height: 12), const SizedBox(height: 12),
OutlinedButton(onPressed: onRetry, child: const Text('Coba lagi')), OutlinedButton(onPressed: onRetry, child: const Text('Coba lagi')),
]), ])),
),
); );
} }
// helpers // helpers
String _fmt(double v, {bool force = false}) { String _fmt(double v, {bool force = false}) {
final fmt = force 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) => '.')
: v.toStringAsFixed(0).replaceAllMapped(RegExp(r'\B(?=(\d{3})+(?!\d))'), (m) => '.'); : v.toStringAsFixed(0).replaceAllMapped(
RegExp(r'\B(?=(\d{3})+(?!\d))'), (m) => '.');
return fmt; return fmt;
} }
@@ -296,5 +380,4 @@ String _rm(double v) =>
String _formatAbs(double v) { String _formatAbs(double v) {
final a = v.abs().toStringAsFixed(0); final a = v.abs().toStringAsFixed(0);
return a.replaceAllMapped(RegExp(r'\B(?=(\d{3})+(?!\d))'), (m) => '.'); return a.replaceAllMapped(RegExp(r'\B(?=(\d{3})+(?!\d))'), (m) => '.');
} }

View File

@@ -15,6 +15,35 @@ class StockService {
return (res as List).map((e) => Saham.fromOdoo(e as Map<String, dynamic>)).toList(); return (res as List).map((e) => Saham.fromOdoo(e as Map<String, dynamic>)).toList();
} }
// Tambah saham baru ke Odoo saat.saham.create
Future<int> addSaham({
required String kode,
String? perusahaan,
String? sektor,
}) async {
final vals = <String, dynamic>{
'name': kode,
if (perusahaan != null && perusahaan.isNotEmpty) 'perusahaan': perusahaan,
if (sektor != null && sektor.isNotEmpty) 'sector': sektor,
};
final res = await client.executeKwd('saat.saham', 'create', args: [vals]);
return (res as num).toInt();
}
// Update data saham (perusahaan, sektor)
Future<void> updateSaham(int id, {String? perusahaan, String? sektor}) async {
final vals = <String, dynamic>{
if (perusahaan != null) 'perusahaan': perusahaan,
if (sektor != null) 'sector': sektor,
};
await client.executeKwd('saat.saham', 'write', args: [[id], vals]);
}
// --- Hapus saham (optional) ---
Future<void> hapusSaham(int id) async {
await client.executeKwd('saat.saham', 'unlink', args: [[id]]);
}
// ---- Portofolio ---- // ---- Portofolio ----
Future<List<Portfolio>> listPortofolio() async { Future<List<Portfolio>> listPortofolio() async {
final fields = [ final fields = [