feat: Invoiceアプリの基本機能を追加
Some checks are pending
Flutter CI / build (push) Waiting to run

Co-authored-by: aider (ollama_chat/7b) <aider@aider.chat>
This commit is contained in:
joe 2026-01-16 09:35:27 +09:00
parent 8ae6d1be62
commit a8242c2a7e
19 changed files with 774 additions and 0 deletions

25
.github/workflows/flutter.yml vendored Normal file
View file

@ -0,0 +1,25 @@
name: Flutter CI
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Flutter
uses: subosito/flutter-action@v1
with:
flutter-version: '3.x'
- name: Install dependencies
run: flutter pub get
- name: Run tests
run: flutter test
- name: Build the app for release
run: flutter build apk --release

111
create_invoice_screen.dart Normal file
View file

@ -0,0 +1,111 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'customer_provider.dart';
import 'product_provider.dart';
import 'invoice_provider.dart';
class CreateInvoiceScreen extends StatefulWidget {
@override
_CreateInvoiceScreenState createState() => _CreateInvoiceScreenState();
}
class _CreateInvoiceScreenState extends State<CreateInvoiceScreen> {
final TextEditingController _quantityController = TextEditingController();
int? _selectedCustomerId;
int? _selectedProductId;
@override
Widget build(BuildContext context) {
final customerProvider = Provider.of<CustomerProvider>(context);
final productProvider = Provider.of<ProductProvider>(context);
final invoiceProvider = Provider.of<InvoiceProvider>(context);
return Scaffold(
appBar: AppBar(
title: Text('請求書作成'),
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
DropdownButtonFormField<int>(
value: _selectedCustomerId,
items: customerProvider.customers.map((customer) {
return DropdownMenuItem<int>(
value: customer.id,
child: Text(customer.name),
);
}).toList(),
onChanged: (value) {
setState(() {
_selectedCustomerId = value;
});
},
decoration: InputDecoration(labelText: '顧客選択'),
),
SizedBox(height: 20),
DropdownButtonFormField<int>(
value: _selectedProductId,
items: productProvider.products.map((product) {
return DropdownMenuItem<int>(
value: product.id,
child: Text(product.name),
);
}).toList(),
onChanged: (value) {
setState(() {
_selectedProductId = value;
});
},
decoration: InputDecoration(labelText: '商品選択'),
),
SizedBox(height: 20),
TextField(
controller: _quantityController,
keyboardType: TextInputType.number,
decoration: InputDecoration(labelText: '数量'),
),
SizedBox(height: 20),
ElevatedButton(
onPressed: () async {
if (_selectedCustomerId != null &&
_selectedProductId != null &&
_quantityController.text.isNotEmpty) {
final quantity = int.parse(_quantityController.text);
final product = productProvider.products.firstWhere((p) => p.id == _selectedProductId);
final invoiceItem = InvoiceItem(
invoiceId: 0, // ID
productId: _selectedProductId!,
quantity: quantity,
unitPrice: product.unitPrice,
discount: product.discount,
);
final total = (product.unitPrice * quantity) - (product.unitPrice * quantity * product.discount);
final tax = total * 0.1; //
final discountTotal = product.unitPrice * quantity * product.discount;
final invoice = Invoice(
customerId: _selectedCustomerId!,
date: DateTime.now().toIso8601String(),
total: total,
tax: tax,
discountTotal: discountTotal,
);
await invoiceProvider.addInvoice(invoice);
// PDF生成など
Navigator.pop(context);
}
},
child: Text('PDF生成'),
),
],
),
),
);
}
}

35
customer.dart Normal file
View file

@ -0,0 +1,35 @@
class Customer {
final int? id;
final String name;
final String phone;
final String address;
final String email;
Customer({
this.id,
required this.name,
required this.phone,
required this.address,
required this.email,
});
Map<String, dynamic> toMap() {
return {
'id': id,
'name': name,
'phone': phone,
'address': address,
'email': email,
};
}
factory Customer.fromMap(Map<String, dynamic> map) {
return Customer(
id: map['id'] as int?,
name: map['name'] as String,
phone: map['phone'] as String,
address: map['address'] as String,
email: map['email'] as String,
);
}
}

35
customer_list_screen.dart Normal file
View file

@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'customer_provider.dart';
class CustomerListScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final customerProvider = Provider.of<CustomerProvider>(context);
return Scaffold(
appBar: AppBar(
title: Text('顧客一覧'),
),
body: FutureBuilder<void>(
future: customerProvider.fetchCustomers(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Center(child: CircularProgressIndicator());
} else {
return ListView.builder(
itemCount: customerProvider.customers.length,
itemBuilder: (context, index) {
final customer = customerProvider.customers[index];
return ListTile(
title: Text(customer.name),
subtitle: Text(customer.phone),
);
},
);
}
},
),
);
}
}

27
customer_provider.dart Normal file
View file

@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
import 'package:sqflite/sqflite.dart';
import 'db_helper.dart';
import 'customer.dart';
class CustomerProvider with ChangeNotifier {
List<Customer> _customers = [];
List<Customer> get customers => _customers;
Future<void> fetchCustomers() async {
final db = await DbHelper().database;
final List<Map<String, dynamic>> maps = await db.query('customers');
_customers = List.generate(maps.length, (i) {
return Customer.fromMap(maps[i]);
});
notifyListeners();
}
Future<void> addCustomer(Customer customer) async {
final db = await DbHelper().database;
await db.insert('customers', customer.toMap());
fetchCustomers();
}
// CRUD
}

41
customer_test.dart Normal file
View file

@ -0,0 +1,41 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:sqflite/sqflite.dart';
import 'package:path_provider/path_provider.dart';
import 'db_helper.dart';
import 'customer.dart';
void main() {
group('Customer Tests', () {
late Database db;
setUp(() async {
final io.Directory documentsDirectory = await getApplicationDocumentsDirectory();
String path = '${documentsDirectory.path}/test.db';
db = await openDatabase(path, version: 1, onCreate: DbHelper()._onCreate);
});
tearDown(() async {
await db.close();
final io.Directory documentsDirectory = await getApplicationDocumentsDirectory();
String path = '${documentsDirectory.path}/test.db';
await io.File(path).delete();
});
test('Add and fetch customer', () async {
final customer = Customer(
name: 'Test Customer',
phone: '1234567890',
address: 'Test Address',
email: 'test@example.com',
);
await db.insert('customers', customer.toMap());
final List<Map<String, dynamic>> maps = await db.query('customers');
expect(maps.length, 1);
final fetchedCustomer = Customer.fromMap(maps.first);
expect(fetchedCustomer.name, 'Test Customer');
});
});
}

63
db_helper.dart Normal file
View file

@ -0,0 +1,63 @@
import 'package:sqflite/sqflite.dart';
import 'package:path_provider/path_provider.dart';
import 'dart:io' as io;
class DbHelper {
static Database? _database;
Future<Database> get database async {
if (_database != null) return _database!;
_database = await initDb();
return _database!;
}
initDb() async {
io.Directory documentsDirectory = await getApplicationDocumentsDirectory();
String path = '${documentsDirectory.path}/invoice.db';
var db = await openDatabase(path, version: 1, onCreate: _onCreate);
return db;
}
void _onCreate(Database db, int version) async {
await db.execute('''
CREATE TABLE customers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT,
phone TEXT,
address TEXT,
email TEXT
)
''');
await db.execute('''
CREATE TABLE products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT,
unit_price REAL,
discount REAL
)
''');
await db.execute('''
CREATE TABLE invoices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_id INTEGER,
date TEXT,
total REAL,
tax REAL,
discount_total REAL
)
''');
await db.execute('''
CREATE TABLE invoice_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
invoice_id INTEGER,
product_id INTEGER,
quantity INTEGER,
unit_price REAL,
discount REAL
)
''');
}
}

39
invoice.dart Normal file
View file

@ -0,0 +1,39 @@
class Invoice {
final int? id;
final int customerId;
final String date;
final double total;
final double tax;
final double discountTotal;
Invoice({
this.id,
required this.customerId,
required this.date,
required this.total,
required this.tax,
required this.discountTotal,
});
Map<String, dynamic> toMap() {
return {
'id': id,
'customer_id': customerId,
'date': date,
'total': total,
'tax': tax,
'discount_total': discountTotal,
};
}
factory Invoice.fromMap(Map<String, dynamic> map) {
return Invoice(
id: map['id'] as int?,
customerId: map['customer_id'] as int,
date: map['date'] as String,
total: map['total'] as double,
tax: map['tax'] as double,
discountTotal: map['discount_total'] as double,
);
}
}

39
invoice_item.dart Normal file
View file

@ -0,0 +1,39 @@
class InvoiceItem {
final int? id;
final int invoiceId;
final int productId;
final int quantity;
final double unitPrice;
final double discount;
InvoiceItem({
this.id,
required this.invoiceId,
required this.productId,
required this.quantity,
required this.unitPrice,
required this.discount,
});
Map<String, dynamic> toMap() {
return {
'id': id,
'invoice_id': invoiceId,
'product_id': productId,
'quantity': quantity,
'unit_price': unitPrice,
'discount': discount,
};
}
factory InvoiceItem.fromMap(Map<String, dynamic> map) {
return InvoiceItem(
id: map['id'] as int?,
invoiceId: map['invoice_id'] as int,
productId: map['product_id'] as int,
quantity: map['quantity'] as int,
unitPrice: map['unit_price'] as double,
discount: map['discount'] as double,
);
}
}

27
invoice_provider.dart Normal file
View file

@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
import 'package:sqflite/sqflite.dart';
import 'db_helper.dart';
import 'invoice.dart';
class InvoiceProvider with ChangeNotifier {
List<Invoice> _invoices = [];
List<Invoice> get invoices => _invoices;
Future<void> fetchInvoices() async {
final db = await DbHelper().database;
final List<Map<String, dynamic>> maps = await db.query('invoices');
_invoices = List.generate(maps.length, (i) {
return Invoice.fromMap(maps[i]);
});
notifyListeners();
}
Future<void> addInvoice(Invoice invoice) async {
final db = await DbHelper().database;
await db.insert('invoices', invoice.toMap());
fetchInvoices();
}
// CRUD
}

42
invoice_test.dart Normal file
View file

@ -0,0 +1,42 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:sqflite/sqflite.dart';
import 'package:path_provider/path_provider.dart';
import 'db_helper.dart';
import 'invoice.dart';
void main() {
group('Invoice Tests', () {
late Database db;
setUp(() async {
final io.Directory documentsDirectory = await getApplicationDocumentsDirectory();
String path = '${documentsDirectory.path}/test.db';
db = await openDatabase(path, version: 1, onCreate: DbHelper()._onCreate);
});
tearDown(() async {
await db.close();
final io.Directory documentsDirectory = await getApplicationDocumentsDirectory();
String path = '${documentsDirectory.path}/test.db';
await io.File(path).delete();
});
test('Add and fetch invoice', () async {
final invoice = Invoice(
customerId: 1,
date: DateTime.now().toIso8601String(),
total: 90.0,
tax: 9.0,
discountTotal: 10.0,
);
await db.insert('invoices', invoice.toMap());
final List<Map<String, dynamic>> maps = await db.query('invoices');
expect(maps.length, 1);
final fetchedInvoice = Invoice.fromMap(maps.first);
expect(fetchedInvoice.customerId, 1);
});
});
}

66
main.dart Normal file
View file

@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
import 'customer_provider.dart';
import 'product_provider.dart';
import 'invoice_provider.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => CustomerProvider()),
ChangeNotifierProvider(create: (_) => ProductProvider()),
ChangeNotifierProvider(create: (_) => InvoiceProvider()),
],
child: MaterialApp(
title: 'Invoice App',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: HomeScreen(),
),
);
}
}
class HomeScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Invoice App'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
ElevatedButton(
onPressed: () {
Navigator.pushNamed(context, '/create_invoice');
},
child: Text('請求書作成'),
),
SizedBox(height: 20),
ElevatedButton(
onPressed: () {
Navigator.pushNamed(context, '/customer_list');
},
child: Text('顧客一覧'),
),
SizedBox(height: 20),
ElevatedButton(
onPressed: () {
Navigator.pushNamed(context, '/product_list');
},
child: Text('商品一覧'),
),
],
),
),
);
}
}

2
path/to/filename.js Normal file
View file

@ -0,0 +1,2 @@
// entire file content ...
// ... goes in between

67
pdf_generator.dart Normal file
View file

@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
import 'package:pdf/pdf.dart';
import 'package:printing/printing.dart';
class PdfGenerator {
static Future<void> generatePdf(Invoice invoice, List<InvoiceItem> items) async {
final pdf = pw.Document();
pdf.addPage(
pw.Page(
build: (pw.Context context) => pw.Column(
children: [
pw.Text('請求書'),
pw.SizedBox(height: 20),
pw.Text('顧客名: ${invoice.customerId}'), //
pw.Text('日付: ${invoice.date}'),
pw.SizedBox(height: 20),
pw.Table(
border: pw.TableBorder.all(),
children: [
pw.TableRow(children: [
pw.Text('商品名'),
pw.Text('数量'),
pw.Text('単価'),
pw.Text('値引き'),
pw.Text('小計'),
]),
for (var item in items)
pw.TableRow(children: [
pw.Text(item.productId.toString()), //
pw.Text(item.quantity.toString()),
pw.Text(item.unitPrice.toStringAsFixed(2)),
pw.Text((item.discount * 100).toStringAsFixed(2) + '%'),
pw.Text((item.unitPrice * item.quantity - (item.unitPrice * item.quantity * item.discount)).toStringAsFixed(2)),
]),
],
),
pw.SizedBox(height: 20),
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text('合計'),
pw.Text(invoice.total.toStringAsFixed(2)),
],
),
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text('税額'),
pw.Text(invoice.tax.toStringAsFixed(2)),
],
),
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text('値引き合計'),
pw.Text(invoice.discountTotal.toStringAsFixed(2)),
],
),
],
),
),
);
await Printing.sharePdf(bytes: pdf.save(), filename: 'invoice.pdf');
}
}

31
product.dart Normal file
View file

@ -0,0 +1,31 @@
class Product {
final int? id;
final String name;
final double unitPrice;
final double discount;
Product({
this.id,
required this.name,
required this.unitPrice,
required this.discount,
});
Map<String, dynamic> toMap() {
return {
'id': id,
'name': name,
'unit_price': unitPrice,
'discount': discount,
};
}
factory Product.fromMap(Map<String, dynamic> map) {
return Product(
id: map['id'] as int?,
name: map['name'] as String,
unitPrice: map['unit_price'] as double,
discount: map['discount'] as double,
);
}
}

35
product_list_screen.dart Normal file
View file

@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'product_provider.dart';
class ProductListScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final productProvider = Provider.of<ProductProvider>(context);
return Scaffold(
appBar: AppBar(
title: Text('商品一覧'),
),
body: FutureBuilder<void>(
future: productProvider.fetchProducts(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Center(child: CircularProgressIndicator());
} else {
return ListView.builder(
itemCount: productProvider.products.length,
itemBuilder: (context, index) {
final product = productProvider.products[index];
return ListTile(
title: Text(product.name),
subtitle: Text('単価: ${product.unitPrice}, 値引き: ${product.discount}'),
);
},
);
}
},
),
);
}
}

27
product_provider.dart Normal file
View file

@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
import 'package:sqflite/sqflite.dart';
import 'db_helper.dart';
import 'product.dart';
class ProductProvider with ChangeNotifier {
List<Product> _products = [];
List<Product> get products => _products;
Future<void> fetchProducts() async {
final db = await DbHelper().database;
final List<Map<String, dynamic>> maps = await db.query('products');
_products = List.generate(maps.length, (i) {
return Product.fromMap(maps[i]);
});
notifyListeners();
}
Future<void> addProduct(Product product) async {
final db = await DbHelper().database;
await db.insert('products', product.toMap());
fetchProducts();
}
// CRUD
}

40
product_test.dart Normal file
View file

@ -0,0 +1,40 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:sqflite/sqflite.dart';
import 'package:path_provider/path_provider.dart';
import 'db_helper.dart';
import 'product.dart';
void main() {
group('Product Tests', () {
late Database db;
setUp(() async {
final io.Directory documentsDirectory = await getApplicationDocumentsDirectory();
String path = '${documentsDirectory.path}/test.db';
db = await openDatabase(path, version: 1, onCreate: DbHelper()._onCreate);
});
tearDown(() async {
await db.close();
final io.Directory documentsDirectory = await getApplicationDocumentsDirectory();
String path = '${documentsDirectory.path}/test.db';
await io.File(path).delete();
});
test('Add and fetch product', () async {
final product = Product(
name: 'Test Product',
unitPrice: 100.0,
discount: 0.1,
);
await db.insert('products', product.toMap());
final List<Map<String, dynamic>> maps = await db.query('products');
expect(maps.length, 1);
final fetchedProduct = Product.fromMap(maps.first);
expect(fetchedProduct.name, 'Test Product');
});
});
}

22
pubspec.yaml Normal file
View file

@ -0,0 +1,22 @@
name: invoice_app
description: A new Flutter project.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
version: 1.0.0+1
environment:
sdk: ">=3.0.0 <4.0.0"
dependencies:
flutter:
sdk: flutter
sqflite: ^2.0.0+3
path_provider: ^2.0.11
pdf: ^3.6.0
printing: ^5.9.3
provider: ^6.0.0
dev_dependencies:
flutter_test:
sdk: flutter