From 1716cf45d0e4e41c7833105dab10542f5ed5dcf1 Mon Sep 17 00:00:00 2001 From: Mica White Date: Sun, 8 Feb 2026 10:10:24 -0500 Subject: Initial commit --- lib/date_utils.dart | 58 ++++++++++ lib/index_file.dart | 119 +++++++++++++++++++++ lib/main.dart | 298 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 475 insertions(+) create mode 100644 lib/date_utils.dart create mode 100644 lib/index_file.dart create mode 100644 lib/main.dart (limited to 'lib') diff --git a/lib/date_utils.dart b/lib/date_utils.dart new file mode 100644 index 0000000..941f137 --- /dev/null +++ b/lib/date_utils.dart @@ -0,0 +1,58 @@ +String pluralize(int count, String noun, [String? plural]) { + if (count == 1) { + return noun; + } else { + return plural ?? '${noun}s'; + } +} + +String number(int number) { + const numbers = [ + 'zero', + 'one', + 'two', + 'three', + 'four', + 'five', + 'six', + 'seven', + 'eight', + 'nine', + 'ten' + ]; + + if (number < numbers.length) { + return numbers[number]; + } else { + return number.toString(); + } +} + +String daysAgo(DateTime dateTime) { + final now = DateTime.now(); + final duration = now.difference(dateTime); + + ago(int count, String noun, [String? plural]) { + return "${number(count)} ${pluralize(count, noun, plural)} ago"; + } + + agoRound(double count, String noun, [String? plural]) { + return ago(count.round(), noun, plural); + } + + if (duration.inSeconds < 60) { + return "now"; + } else if (duration.inMinutes < 60) { + return ago(duration.inMinutes, "minute"); + } else if (duration.inHours < 24) { + return ago(duration.inHours, "hour"); + } else if (duration.inDays < 7) { + return ago(duration.inDays, "day"); + } else if (duration.inDays < 30) { + return agoRound(duration.inDays / 7, "week"); + } else if (duration.inDays < 365) { + return agoRound(duration.inDays / 30, "month"); + } else { + return agoRound(duration.inDays / 365, "years"); + } +} diff --git a/lib/index_file.dart b/lib/index_file.dart new file mode 100644 index 0000000..9b434ed --- /dev/null +++ b/lib/index_file.dart @@ -0,0 +1,119 @@ +import 'dart:convert'; + +import 'package:archive/archive.dart'; +import 'package:flutter/material.dart'; +import 'package:image/image.dart'; + +enum ProjectType { video, image, sound } + +extension PTExt on ProjectType { + IconData icon() { + switch (this) { + case ProjectType.video: + return Icons.video_file; + case ProjectType.image: + return Icons.image; + case ProjectType.sound: + return Icons.audio_file; + } + } + + String normalString() { + switch (this) { + case ProjectType.video: + return 'Video'; + case ProjectType.image: + return 'Image'; + case ProjectType.sound: + return 'Sound'; + } + } + + static ProjectType parse(String type) { + switch (type) { + case 'Video': + return ProjectType.video; + case 'Image': + return ProjectType.image; + case 'Sound': + return ProjectType.sound; + default: + throw ArgumentError.value(type); + } + } +} + +class ProjectMetadata { + final String title; + final ProjectType type; + final DateTime lastModified; + final String? location; + + const ProjectMetadata({ + this.title = 'My New Project', + required this.type, + required this.lastModified, + this.location, + }); +} + +class ProjectIndex { + final Archive archive; + final List projects; + + const ProjectIndex({ + required this.archive, + required this.projects, + }); + + factory ProjectIndex.blank() { + final archive = Archive(); + final indexFile = ArchiveFile.string('index.json', '[]'); + archive.addFile(indexFile); + + return ProjectIndex(archive: archive, projects: [ + ProjectMetadata( + title: 'My first project', + type: ProjectType.video, + lastModified: DateTime.utc(2025), + ), + ProjectMetadata( + title: 'Project #2', + type: ProjectType.image, + lastModified: DateTime.utc(2024, DateTime.december), + ), + ]); + } + + factory ProjectIndex.load(String path) { + final file = InputFileStream(path); + final archive = ZipDecoder().decodeStream(file); + + var projects = []; + final index = archive.findFile('index.json')!; + List> json = jsonDecode(index.getContent().toString()); + for (final entry in json) { + projects.add(ProjectMetadata( + title: entry['title']!, + type: PTExt.parse(entry['type']!), + location: entry['location'], + lastModified: DateTime.parse(entry['lastModified']!), + )); + } + projects.sort((a, b) => b.lastModified.compareTo(a.lastModified)); + + return ProjectIndex( + archive: archive, + projects: projects, + ); + } + + getImage(String title) { + final file = archive.find("$title.jpg"); + if (file == null) { + return null; + } + + return JpegDecoder().decode(file.content)?.getBytes(); + } +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..d35e631 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,298 @@ +import 'dart:typed_data'; + +import 'package:dadtuber/date_utils.dart'; +import 'package:dadtuber/index_file.dart'; +import 'package:flutter/material.dart'; +import 'package:path_provider/path_provider.dart'; + +void main() { + runApp(const MyApp()); +} + +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + // This widget is the root of your application. + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'DadTuber', + theme: ThemeData( + // This is the theme of your application. + // + // TRY THIS: Try running your application with "flutter run". You'll see + // the application has a purple toolbar. Then, without quitting the app, + // try changing the seedColor in the colorScheme below to Colors.green + // and then invoke "hot reload" (save your changes or press the "hot + // reload" button in a Flutter-supported IDE, or press "r" if you used + // the command line to start the app). + // + // Notice that the counter didn't reset back to zero; the application + // state is not lost during the reload. To reset the state, use hot + // restart instead. + // + // This works for code too, not just values: Most code changes can be + // tested with just a hot reload. + colorScheme: ColorScheme.fromSeed(seedColor: Colors.red), + useMaterial3: true, + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom(minimumSize: Size.fromRadius(24)), + ), + ), + home: const MyHomePage(title: 'DadTuber'), + ); + } +} + +class MyHomePage extends StatefulWidget { + const MyHomePage({super.key, required this.title}); + + // This widget is the home page of your application. It is stateful, meaning + // that it has a State object (defined below) that contains fields that affect + // how it looks. + + // This class is the configuration for the state. It holds the values (in this + // case the title) provided by the parent (in this case the App widget) and + // used by the build method of the State. Fields in a Widget subclass are + // always marked "final". + + final String title; + + @override + State createState() => _MyHomePageState(); +} + +class _MyHomePageState extends State { + late Future _projectIndexBuilder; + + @override + void initState() { + super.initState(); + + builder() async { + final dir = await getApplicationDocumentsDirectory(); + final path = "${dir.path}/index"; + + try { + return ProjectIndex.load(path); + } catch (_) { + return ProjectIndex.blank(); + } + } + + _projectIndexBuilder = builder(); + } + + @override + Widget build(BuildContext context) { + // This method is rerun every time setState is called, for instance as done + // by the _incrementCounter method above. + // + // The Flutter framework has been optimized to make rerunning build methods + // fast, so that you can just rebuild anything that needs updating rather + // than having to individually change instances of widgets. + return Scaffold( + appBar: AppBar( + // TRY THIS: Try changing the color here to a specific color (to + // Colors.amber, perhaps?) and trigger a hot reload to see the AppBar + // change color while the other colors stay the same. + backgroundColor: Theme.of(context).colorScheme.inversePrimary, + // Here we take the value from the MyHomePage object that was created by + // the App.build method, and use it to set our appbar title. + title: Text(widget.title), + ), + body: FocusTraversalGroup( + policy: WidgetOrderTraversalPolicy(), + child: OrientationBuilder( + builder: (context, orientation) => + orientation == Orientation.landscape + ? Container( + padding: EdgeInsets.all(24), + child: Row( + spacing: 48, + children: [ + CreateNewColumn(showHeader: true), + Expanded( + child: SavedProjectsColumn( + projectIndexBuilder: _projectIndexBuilder, + showHeader: true, + ), + ), + ], + ), + ) + : Column( + spacing: 16, + children: [ + CreateNewColumn(showHeader: false), + Expanded( + child: SavedProjectsColumn( + projectIndexBuilder: _projectIndexBuilder, + showHeader: false, + ), + ), + ], + ), + ), + ), + ); + } +} + +class CreateNewColumn extends StatelessWidget { + final bool showHeader; + + const CreateNewColumn({required this.showHeader, super.key}); + + @override + Widget build(BuildContext context) { + return Column( + spacing: 12, + children: [ + showHeader + ? Text( + "Create New", + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + ) + : SizedBox(), + ElevatedButton.icon( + icon: Icon(Icons.video_file), + onPressed: () => (), + label: Text('Create a new video'), + ), + ElevatedButton.icon( + icon: Icon(Icons.image), + onPressed: () => (), + label: Text('Create a new image'), + ), + ElevatedButton.icon( + icon: Icon(Icons.audio_file), + onPressed: () => (), + label: Text('Create a new sound'), + ), + ], + ); + } +} + +class SavedProjectsColumn extends StatelessWidget { + final bool showHeader; + final Future projectIndexBuilder; + + const SavedProjectsColumn({ + required this.projectIndexBuilder, + required this.showHeader, + super.key, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.symmetric(horizontal: 12), + child: Text( + "Saved Projects", + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + ), + ), + Expanded( + child: FutureBuilder( + future: projectIndexBuilder, + builder: (cx, snapshot) { + if (snapshot.hasData) { + return SavedProjectsListView(snapshot.data!); + } else if (snapshot.hasError) { + return Center(child: Text("An error occurred")); + } else { + return Center(child: CircularProgressIndicator()); + } + }, + ), + ), + ], + ); + } +} + +class SavedProjectsListView extends StatelessWidget { + final ProjectIndex _projectIndex; + + const SavedProjectsListView(this._projectIndex, {super.key}); + + @override + Widget build(BuildContext context) { + return ListView.builder( + addAutomaticKeepAlives: false, + itemCount: _projectIndex.projects.length, + itemBuilder: (cx, idx) { + final project = _projectIndex.projects[idx]; + return SavedProjectTile( + title: project.title, + image: _projectIndex.getImage(project.title), + lastModified: project.lastModified, + projectType: project.type, + ); + }, + ); + } +} + +class SavedProjectTile extends StatelessWidget { + final String title; + final Uint8List? image; + final DateTime lastModified; + final ProjectType projectType; + + const SavedProjectTile({ + required this.title, + this.image, + required this.lastModified, + required this.projectType, + super.key, + }); + + @override + Widget build(BuildContext context) { + return ListTile( + leading: SavedProjectThumbnail(projectType: projectType, image: image), + title: Text( + title, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text('Edited ${daysAgo(lastModified)}'), + trailing: Text(projectType.normalString()), + onTap: () => (), + ); + } +} + +class SavedProjectThumbnail extends StatelessWidget { + final ProjectType projectType; + final Uint8List? image; + + const SavedProjectThumbnail({ + required this.projectType, + required this.image, + super.key, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 96, + height: 54, + child: Stack( + children: [ + image != null + ? Image.memory(image!, width: 80, height: 45) + : SizedBox(width: 80, height: 45), + Center( + child: Opacity(opacity: 0.5, child: Icon(projectType.icon())), + ), + ], + ), + ); + } +} -- cgit v1.2.3