はじめに
アメリカ滞在中にハマったことがあります。鉱石を集めることです。
- 旅先の鉱石ショップで、その土地でしか出会えない地場の石を買う
- 海岸や河原に拾いに行く
- 拾ってきた石をタンブラー(研磨機)に研磨剤と一緒に入れて、何週間もかけてピカピカにする
- 磨き上がった石をワイヤーで巻いてアクセサリーにする
やっているうちに「あれ、この石どこで拾ったんだっけ?」「このタンブリング、何番の研磨剤で何日回したっけ?」がどんどん分からなくなってきました。写真アプリには石の写真だけが溜まっていく。メモアプリには断片的な記録が散らばる。
鉱石に特化した、採集・記録・共有ができるアプリがあればいいのに——と思って探しても、ちょうどいいものが見つからない。
なら作るか、ということで作ったのが 鉱石ノート(Mineral Note) です。
- App Store: https://apps.apple.com/jp/app/mineral-note/id6770812253
- Google Play: https://play.google.com/store/apps/details?id=com.cobostone.app&hl=ja
- 公式サイト: https://mineralnote.app
iOS / Android 両対応、無料・広告なし、日本語 / 英語対応です。
この記事では どんなアプリなのか と、個人開発でどう組んだのか(構成と設計上の勘所) の両方を書きます。
こんな人向けのアプリです
- 海岸や河原で石を拾うのが好きな人
- 旅先で鉱石ショップを巡るのが好きな人
- タンブリングやワイヤーラッピングなど、石を「加工して作品にする」のが好きな人
- 自分のコレクションをきちんと記録・整理したい人
要するに、自分のような人です。
何ができるのか
1. 世界中の鉱石スポットを地図で探す
地図を動かすと、その範囲にある採集スポットや鉱石ショップが出てきます。鉱物 / 宝石 / 岩石 / ショップなどのカテゴリで絞り込みも可能。
旅行前に「この辺りで何か拾えるかな」と眺めたり、逆に自分の知っているスポットを世界の誰かに教えたり、という使い方を想定しています。
2. 採集した石をノートに記録する
拾った石を、写真・場所・日付・石の種類・メモでまとめて記録します。「浜辺で拾った石(Minamiizu)」「アゲートのタンブリング」のように、採集記録も加工記録も同じノートに積み上がっていきます。
野帳(フィールドノート)のイメージです。リスト表示とマップ表示を切り替えられるので、「自分がどこで石を拾ってきたか」が地図上に溜まっていくのが地味に楽しい。
写真は電波の無い河原や海岸でも撮って保存でき、オンラインに復帰したタイミングで自動同期されます(後述)。
3. 石の名前がわからないときは AI に候補を出してもらう
石の記録で一番つまずくのが「これ何ていう石?」です。投稿ステップの中で写真を渡すと、AI が石タグの候補と理由・確度を返してくれます。
重要なのは、これを鑑定にしないことでした。鉱物同定は写真だけでできるものではないので、UI 上でも「正確な鑑定ではありません」と明示し、あくまでタグ付けの入口として置いています。候補をタップするとタグが追加され、違うと思えば無視して自分で検索して選べます。
4. 自慢の石をギャラリーで共有する
磨き上がった石、作ったアクセサリー、コレクションの全景。位置情報なしで写真だけを共有する場としてギャラリーを分けています。「記録(ノート)」と「見せたい作品(ギャラリー)」は目的が違うので、別タブにしました。
技術構成
ここからは開発の話です。
-
フロント: Flutter(iOS / Android)
- 状態管理: Riverpod、ルーティング: go_router
- 地図: Mapbox(
mapbox_maps_flutter) - ローカル DB: sqflite(オフライン下書き)
-
バックエンド: AWS Amplify Gen2(TypeScript / CDK)
- 認証: Cognito(メール + Google + Sign in with Apple)
- API: AppSync(GraphQL)+ DynamoDB
- ストレージ: S3 + CloudFront
- Lambda: サムネイル生成 / 地図検索 / カウント集計 / AI 候補 / プッシュ送信 / 退会処理 など
- その他: Firebase(FCM・Analytics・Crashlytics)、ランディングページは Next.js
以下、個人開発なりに悩んだところを 4 つ書きます。
① 地図の広域検索を DynamoDB でどうやるか(geohash バケット + GSI)
一番設計に悩んだのが地図です。地図アプリは「今表示している矩形範囲のスポットを全部くれ」というクエリが必要になりますが、DynamoDB は素直に範囲検索できません。かといって全件 Scan は論外です。
やったことはシンプルで、スポット保存時に geohash のプレフィックスを 3・4・5 桁で持たせ、それぞれを GSI のパーティションキーにするというものです。
// amplify/data/resource.ts(抜粋)
geoBucket3: a.string(), // Geohash prefix 3(広域検索用)
geoBucket4: a.string(), // Geohash prefix 4(中域検索用)
geoBucket5: a.string(), // Geohash prefix 5(詳細検索用)
.secondaryIndexes(index => [
index('geoBucket3').sortKeys(['createdAt']).queryField('listSpotsByGeoBucket3'),
index('geoBucket4').sortKeys(['createdAt']).queryField('listSpotsByGeoBucket4'),
index('geoBucket5').sortKeys(['createdAt']).queryField('listSpotsByGeoBucket5'),
])
検索側は AppSync のカスタムクエリから Lambda を呼び、ズームレベルに応じて使う精度を切り替え、表示範囲に重なるバケットだけを並列 Query します。世界地図まで引いたら精度 3、街レベルまで寄ったら精度 5、というイメージです。バケット数が増えすぎる場合は上限で打ち切って truncated を返し、アプリ側で「もっと拡大してください」的な扱いにします。
const bucketIndexByPrecision: Record<number, string> = {
3: 'spotsByGeoBucket3AndCreatedAt',
4: 'spotsByGeoBucket4AndCreatedAt',
// ...
};
Elasticsearch / OpenSearch を建てれば楽ですが、個人開発で常時起動のクラスタを抱えるのは避けたかった。DynamoDB の GSI だけで完結させたことで、地図がどれだけ叩かれてもコストが読めるのが大きなメリットでした。
② 「場所を共有する」アプリのプライバシーと保護
鉱石の採集スポットは、公開すればするほど良い、という情報ではありません。私有地かもしれないし、天然記念物や保護区かもしれないし、人が殺到すれば荒らされる場所かもしれない。
そこで、投稿には 3 段階の公開範囲を持たせました。
visibilityLevel: a.enum(['area', 'exact', 'private']),
// エリアのみ / 詳細座標 / 非公開
area(エリアのみ)を選ぶと、保存時点で緯度経度を小数第 1 位に丸めてから公開します(0.1 度 ≒ 約 11km 四方)。「南伊豆のどこか」までは分かるが、ピンポイントの場所は出ない、という状態です。表示のときにぼかすのではなく保存時に丸めるのがポイントで、詳細座標はそもそもサーバに残りません。
加えて、管理者が「採集禁止・制限エリア」を登録できるようにし、そのエリア内のスポット投稿は公開範囲に関わらず審査に回るようにしています。
reviewStatus: a.enum(['pending', 'approved', 'rejected', 'area_downgraded']),
reviewNote: a.string(), // 否認・降格時の理由
reapplyCount: a.integer(), // 再申請は最大3回
lastRejectedAt: a.datetime(),// 否認後7日はクールダウン
area_downgraded は「投稿自体は活かすが、詳細座標での公開はやめてエリアのみに落とす」ための状態です。全否認しかできないと、良い投稿まで消えてしまうので。
UGC アプリなので、通報・ブロック機能(ストア審査の必須要件でもある)も一通り入れています。この辺りは「作りたい機能」ではないけれど、他人の投稿を扱う以上は避けて通れないところでした。
③ 電波の無いところで使われる前提にする(オフライン対応)
石を拾う場所は、だいたい電波が悪いです。山、河原、海岸。ここで「通信エラー」と言われて記録が飛ぶアプリは使い物になりません。
なので採集記録は、
- オフラインでも写真・位置・タグ・メモを sqflite にローカル保存(写真もアプリ領域に永続化)
- 一覧の先頭に「未同期」バッジ付きで表示
- オンライン復帰を検知したら自動でアップロード&同期、「N件の採集記録を同期しました」と通知
という流れにしています。
ハマったのはむしろ細部で、たとえば
- アプリをタスクキルしても写真が消えないか(一時ディレクトリに置くと消える)
- 同期トリガーが連発したときに二重アップロードされないか
- 同期後のレコードに
geoBucketがちゃんと入っていて、地図に出るか
このあたりは自動テストで担保しきれないので、手動 QA のシナリオを Markdown で書き起こして、リリース前に毎回なぞるようにしました。個人開発でも「壊れやすい非自明なところ」だけはチェックリスト化しておくと、後の自分が助かります。
④ AI 機能のコストを個人開発で破綻させない
AI 候補機能は Lambda(Python)から Gemini API を呼んでいます。API キーは Secrets Manager に置き、アプリからは API Gateway 経由で叩く形です。
個人開発でこの手の機能を出すとき、一番怖いのはコストが青天井になることなので、AiUsageCounter というモデルでユーザーごと・期間ごとの利用回数を数え、上限を超えたら弾くようにしています。UI にも「今月あと 28 回」と残数を出していて、これは制限としてだけでなく「無限じゃないから、ここぞという写真で使おう」という体験の一部にもなっています。
画像はそのまま投げず、アプリ側で圧縮してから送っています(flutter_image_compress)。
おまけ:Lambda で細かい後処理を回す
- thumbnail: S3 の画像アップロードをイベントで受けてサムネイル生成。配信は CloudFront。
- countUpdater: いいね・コメントの DynamoDB Streams を受けて、対象投稿のカウンタ更新と通知レコードの作成。カウントをアプリ側やクエリ側で数えないので、一覧が軽い。
- pushSender: 通知レコードから FCM / APNs へ。SQS + DLQ 経由で、失敗しても落ちっぱなしにならないように。
Amplify Gen2 は、この「Amplify の定義 + 素の CDK」を同じコードベースに書けるのが良かったです。認証や GraphQL は Amplify に任せて、S3 イベント・Streams・CloudFront・CloudWatch アラーム・SNS 通知みたいなインフラ寄りの部分は CDK でそのまま書ける。個人開発だとマネージドに乗れるところは全部乗るのが正解でした。
作ってみて思ったこと
「自分がユーザーである」ことの強さは、想像以上でした。機能を迷ったときに「自分は河原でこれ使うか?」で判断できるので、仕様会議が一瞬で終わります。オフライン対応やエリア公開の丸め込みは、実際に自分が海岸で使って初めて必要性に気づいた機能です。
一方で、アプリを作る時間より、アプリ以外の時間のほうが長かったのも事実です。ストア用スクリーンショット、プライバシーポリシー、利用規約、コミュニティガイドライン、通報・ブロック、アカウント削除導線、ランディングページ、日英ローカライズ。「個人開発は実装が 3 割」というのは本当でした。
これから
- スポット情報の充実(各地の採集地・鉱石ショップ)
- コレクションの整理機能の強化
- タンブリングの工程記録をもっとちゃんと残せるように
鉱石が好きな方、石を拾うのが好きな方は、ぜひ触ってみてください。「こういう機能が欲しい」という声が一番のモチベーションになります。
- App Store: https://apps.apple.com/jp/app/mineral-note/id6770812253
- Google Play: https://play.google.com/store/apps/details?id=com.cobostone.app&hl=ja
- 公式サイト: https://mineralnote.app
最後に、鉱石を拾いに行くときのお願いです。私有地・保護区・立入禁止の場所には入らない、採集が禁止されている場所では採らない。石は逃げませんが、拾える場所は簡単に失われます。アプリ側でも制限エリアの審査を入れているのは、そういう理由です。
ここまで読んでいただきありがとうございました。




